From 3ee4f96b688b3f62a98d6f03c009af47b97b4d69 Mon Sep 17 00:00:00 2001 From: Ricardo Mestre Date: Wed, 29 Jan 2025 18:57:21 +0000 Subject: [PATCH] Add proxy onboarding feature # Conflicts: # java/code/src/com/suse/manager/webui/services/SaltServerActionService.java --- java/buildconf/LICENSE.txt | 2 +- .../rhn/common/ErrorReportingStrategies.java | 20 +- .../src/com/redhat/rhn/common/RhnError.java | 37 + .../com/redhat/rhn/common/RhnErrorReport.java | 66 ++ .../rhn/common/RhnGeneralException.java | 47 ++ .../redhat/rhn/common/RhnReportStrategy.java | 27 + .../db/datasource/xml/System_queries.xml | 1 + .../rhn/common/security/acl/Access.java | 26 + .../rhn/domain/action/ActionFactory.java | 10 +- .../action/ProxyConfigurationApplyAction.java | 75 ++ .../domain/entitlement/ProxyEntitlement.java | 71 ++ .../com/redhat/rhn/domain/server/Server.java | 17 + .../rhn/domain/server/ServerConstants.java | 10 +- .../strings/java/StringResource_en_US.xml | 12 + .../strings/jsp/StringResource_en_US.xml | 3 + .../redhat/rhn/frontend/taglibs/IconTag.java | 2 + .../rhn/frontend/taglibs/ToolbarTag.java | 109 ++- .../rhn/frontend/taglibs/rhn-taglib.tld | 10 + .../activationkey/ActivationKeyHandler.java | 3 + .../entitlement/EntitlementManager.java | 8 + .../rhn/manager/system/SystemManager.java | 52 +- .../ProxyContainerConfigCreate.java | 52 +- .../src/com/suse/manager/webui/Router.java | 7 +- .../ProxyConfigurationController.java | 344 ++++++++ .../services/SaltServerActionService.java | 18 +- .../webui/templates/minion/proxy-config.jade | 21 + .../webui/templates/system-common.jade | 4 + .../webui/utils/SparkApplicationHelper.java | 11 + .../utils/gson/ProxyConfigUpdateJson.java | 157 ++++ .../src/com/suse/proxy/ProxyConfigUtils.java | 289 +++++++ .../suse/proxy/ProxyContainerImagesEnum.java | 70 ++ .../com/suse/proxy/ProxyRegistryUtils.java | 200 +++++ java/code/src/com/suse/proxy/RegistryUrl.java | 120 +++ .../com/suse/proxy/get/ProxyConfigGet.java | 43 + .../src/com/suse/proxy/model/ProxyConfig.java | 159 ++++ .../suse/proxy/model/ProxyConfigImage.java | 54 ++ .../suse/proxy/update/ProxyConfigUpdate.java | 62 ++ .../update/ProxyConfigUpdateAcquisitor.java | 166 ++++ .../ProxyConfigUpdateApplySaltState.java | 75 ++ .../update/ProxyConfigUpdateContext.java | 177 ++++ .../ProxyConfigUpdateContextHandler.java | 24 + .../ProxyConfigUpdateFileAcquisitor.java | 104 +++ ...roxyConfigUpdateRegistryPreConditions.java | 61 ++ .../update/ProxyConfigUpdateSavePillars.java | 86 ++ .../update/ProxyConfigUpdateValidation.java | 165 ++++ java/code/src/com/suse/rest/RestClient.java | 152 ++++ .../com/suse/rest/RestClientException.java | 96 +++ java/code/src/com/suse/rest/RestRequest.java | 90 +++ .../com/suse/rest/RestRequestAuthEnum.java | 25 + .../src/com/suse/rest/RestRequestBuilder.java | 164 ++++ .../com/suse/rest/RestRequestMethodEnum.java | 23 + java/code/src/com/suse/rest/RestResponse.java | 88 ++ .../code/webapp/WEB-INF/nav/system_detail.xml | 5 + .../fragments/systems/system-header.jspf | 4 +- java/code/webapp/WEB-INF/struts-config.xml | 1 + ...nges.rjpmestre.simplified-proxy-onboarding | 1 + .../spacewalk/common/data/rhnActionType.sql | 2 + .../common/data/rhnSGTypeBaseAddonCompat.sql | 5 + .../common/data/rhnServerGroupType.sql | 11 + .../data/rhnServerServerGroupArchCompat.sql | 24 + .../postgres/procs/create_new_org.sql | 9 + ...nges.rjpmestre.simplified-proxy-onboarding | 1 + .../010-proxy-entitled.sql | 72 ++ ...-proxy_configuration_apply-action-type.sql | 3 + .../salt/apply_proxy_config.sls | 141 ++++ .../salt/install_mgrpxy.service | 22 + ...nges.rjpmestre.simplified-proxy-onboarding | 1 + web/html/src/components/buttons.tsx | 7 + web/html/src/components/icontag.tsx | 1 + web/html/src/manager/minion/index.ts | 1 + .../minion/proxy/proxy-config-messages.tsx | 40 + .../minion/proxy/proxy-config.renderer.tsx | 25 + .../src/manager/minion/proxy/proxy-config.tsx | 755 ++++++++++++++++++ web/html/src/manager/systems/list-filter.tsx | 1 + ...nges.rjpmestre.simplified-proxy-onboarding | 1 + 75 files changed, 4824 insertions(+), 24 deletions(-) create mode 100644 java/code/src/com/redhat/rhn/common/RhnError.java create mode 100644 java/code/src/com/redhat/rhn/common/RhnErrorReport.java create mode 100644 java/code/src/com/redhat/rhn/common/RhnGeneralException.java create mode 100644 java/code/src/com/redhat/rhn/common/RhnReportStrategy.java create mode 100644 java/code/src/com/redhat/rhn/domain/action/ProxyConfigurationApplyAction.java create mode 100644 java/code/src/com/redhat/rhn/domain/entitlement/ProxyEntitlement.java create mode 100644 java/code/src/com/suse/manager/webui/controllers/ProxyConfigurationController.java create mode 100644 java/code/src/com/suse/manager/webui/templates/minion/proxy-config.jade create mode 100644 java/code/src/com/suse/manager/webui/utils/gson/ProxyConfigUpdateJson.java create mode 100644 java/code/src/com/suse/proxy/ProxyConfigUtils.java create mode 100644 java/code/src/com/suse/proxy/ProxyContainerImagesEnum.java create mode 100644 java/code/src/com/suse/proxy/ProxyRegistryUtils.java create mode 100644 java/code/src/com/suse/proxy/RegistryUrl.java create mode 100644 java/code/src/com/suse/proxy/get/ProxyConfigGet.java create mode 100644 java/code/src/com/suse/proxy/model/ProxyConfig.java create mode 100644 java/code/src/com/suse/proxy/model/ProxyConfigImage.java create mode 100644 java/code/src/com/suse/proxy/update/ProxyConfigUpdate.java create mode 100644 java/code/src/com/suse/proxy/update/ProxyConfigUpdateAcquisitor.java create mode 100644 java/code/src/com/suse/proxy/update/ProxyConfigUpdateApplySaltState.java create mode 100644 java/code/src/com/suse/proxy/update/ProxyConfigUpdateContext.java create mode 100644 java/code/src/com/suse/proxy/update/ProxyConfigUpdateContextHandler.java create mode 100644 java/code/src/com/suse/proxy/update/ProxyConfigUpdateFileAcquisitor.java create mode 100644 java/code/src/com/suse/proxy/update/ProxyConfigUpdateRegistryPreConditions.java create mode 100644 java/code/src/com/suse/proxy/update/ProxyConfigUpdateSavePillars.java create mode 100644 java/code/src/com/suse/proxy/update/ProxyConfigUpdateValidation.java create mode 100644 java/code/src/com/suse/rest/RestClient.java create mode 100644 java/code/src/com/suse/rest/RestClientException.java create mode 100644 java/code/src/com/suse/rest/RestRequest.java create mode 100644 java/code/src/com/suse/rest/RestRequestAuthEnum.java create mode 100644 java/code/src/com/suse/rest/RestRequestBuilder.java create mode 100644 java/code/src/com/suse/rest/RestRequestMethodEnum.java create mode 100644 java/code/src/com/suse/rest/RestResponse.java create mode 100644 java/spacewalk-java.changes.rjpmestre.simplified-proxy-onboarding create mode 100644 schema/spacewalk/susemanager-schema.changes.rjpmestre.simplified-proxy-onboarding create mode 100644 schema/spacewalk/upgrade/susemanager-schema-5.1.2-to-susemanager-schema-5.1.3/010-proxy-entitled.sql create mode 100644 schema/spacewalk/upgrade/susemanager-schema-5.1.2-to-susemanager-schema-5.1.3/011-add-proxy_configuration_apply-action-type.sql create mode 100644 susemanager-utils/susemanager-sls/salt/apply_proxy_config.sls create mode 100644 susemanager-utils/susemanager-sls/salt/install_mgrpxy.service create mode 100644 susemanager-utils/susemanager-sls/susemanager-sls.changes.rjpmestre.simplified-proxy-onboarding create mode 100644 web/html/src/manager/minion/proxy/proxy-config-messages.tsx create mode 100644 web/html/src/manager/minion/proxy/proxy-config.renderer.tsx create mode 100644 web/html/src/manager/minion/proxy/proxy-config.tsx create mode 100644 web/spacewalk-web.changes.rjpmestre.simplified-proxy-onboarding diff --git a/java/buildconf/LICENSE.txt b/java/buildconf/LICENSE.txt index 0c52c8aea8fc..e737706cd03b 100644 --- a/java/buildconf/LICENSE.txt +++ b/java/buildconf/LICENSE.txt @@ -1,5 +1,5 @@ ^/\*$ -(^ \* Copyright \(c\) (20([0123]\d|20)--)?20(1\d|2[01234]) (Red Hat, Inc.|SUSE LLC)$)+ +(^ \* Copyright \(c\) (20([01234]\d|20)--)?20(1\d|2[012345]) (Red Hat, Inc.|SUSE LLC)$)+ ^ \*$ ^ \* This software is licensed to you under the GNU General Public License,$ ^ \* version 2 \(GPLv2\). There is NO WARRANTY for this software, express or$ diff --git a/java/code/src/com/redhat/rhn/common/ErrorReportingStrategies.java b/java/code/src/com/redhat/rhn/common/ErrorReportingStrategies.java index fb3787fb2adf..28c69ac62b7b 100644 --- a/java/code/src/com/redhat/rhn/common/ErrorReportingStrategies.java +++ b/java/code/src/com/redhat/rhn/common/ErrorReportingStrategies.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2024 SUSE LLC + * Copyright (c) 2024-2025 SUSE LLC * * This software is licensed to you under the GNU General Public License, * version 2 (GPLv2). There is NO WARRANTY for this software, express or @@ -32,6 +32,24 @@ private ErrorReportingStrategies() { } private static final Map OBJ_LOGGER = Collections.synchronizedMap(new WeakHashMap<>()); + private static final RhnReportStrategy VALIDATION_REPORT_STRATEGY; + + static { + VALIDATION_REPORT_STRATEGY = errors -> { + if (!errors.isEmpty()) { + throw new RhnGeneralException(errors); + } + }; + } + + + /** + * Returns a default validation reporting strategy + * @return RhnReportStrategy + */ + public static RhnReportStrategy validationReportingStrategy() { + return VALIDATION_REPORT_STRATEGY; + } /** * Raise and log an exception diff --git a/java/code/src/com/redhat/rhn/common/RhnError.java b/java/code/src/com/redhat/rhn/common/RhnError.java new file mode 100644 index 000000000000..2d3a4f7c71cb --- /dev/null +++ b/java/code/src/com/redhat/rhn/common/RhnError.java @@ -0,0 +1,37 @@ +/* + * Copyright (c) 2025 SUSE LLC + * + * This software is licensed to you under the GNU General Public License, + * version 2 (GPLv2). There is NO WARRANTY for this software, express or + * implied, including the implied warranties of MERCHANTABILITY or FITNESS + * FOR A PARTICULAR PURPOSE. You should have received a copy of GPLv2 + * along with this software; if not, see + * http://www.gnu.org/licenses/old-licenses/gpl-2.0.txt. + * + * Red Hat trademarks are not licensed under GPLv2. No permission is + * granted to use or replicate Red Hat trademarks that are incorporated + * in this software or its documentation. + */ + +package com.redhat.rhn.common; + +import java.io.Serializable; + +/** + * Represents a base error + */ +public class RhnError implements Serializable { + private final String message; + + /** + * Constructor + * @param messageIn the error message + */ + public RhnError(String messageIn) { + this.message = messageIn; + } + + public String getMessage() { + return message; + } +} diff --git a/java/code/src/com/redhat/rhn/common/RhnErrorReport.java b/java/code/src/com/redhat/rhn/common/RhnErrorReport.java new file mode 100644 index 000000000000..0e47c4eef38f --- /dev/null +++ b/java/code/src/com/redhat/rhn/common/RhnErrorReport.java @@ -0,0 +1,66 @@ +/* + * Copyright (c) 2025 SUSE LLC + * + * This software is licensed to you under the GNU General Public License, + * version 2 (GPLv2). There is NO WARRANTY for this software, express or + * implied, including the implied warranties of MERCHANTABILITY or FITNESS + * FOR A PARTICULAR PURPOSE. You should have received a copy of GPLv2 + * along with this software; if not, see + * http://www.gnu.org/licenses/old-licenses/gpl-2.0.txt. + * + * Red Hat trademarks are not licensed under GPLv2. No permission is + * granted to use or replicate Red Hat trademarks that are incorporated + * in this software or its documentation. + */ + +package com.redhat.rhn.common; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +public class RhnErrorReport { + private final List errors = Collections.synchronizedList(new ArrayList<>()); + + /** + * Registers a new error in the error report. + * + * @param message The error message. + */ + public void register(String message) { + errors.add(new RhnError(message)); + } + + /** + * Checks if any errors have been registered. + * + * @return true if there are errors; false otherwise. + */ + public boolean hasErrors() { + return !errors.isEmpty(); + } + + /** + * Returns a copy of the current list of errors. + * + * @return A copy of the errors list. + */ + public List getErrors() { + return new ArrayList<>(errors); + } + + /** + * Logs the errors following a RhnReportStrategy. + * @param strategy The reporting strategy. + */ + public void report(RhnReportStrategy strategy) { + strategy.report(errors); + } + + /** + * Logs the errors using the default validation reporting strategy. + */ + public void report() { + ErrorReportingStrategies.validationReportingStrategy().report(errors); + } +} diff --git a/java/code/src/com/redhat/rhn/common/RhnGeneralException.java b/java/code/src/com/redhat/rhn/common/RhnGeneralException.java new file mode 100644 index 000000000000..b144d1c04182 --- /dev/null +++ b/java/code/src/com/redhat/rhn/common/RhnGeneralException.java @@ -0,0 +1,47 @@ +/* + * Copyright (c) 2025 SUSE LLC + * + * This software is licensed to you under the GNU General Public License, + * version 2 (GPLv2). There is NO WARRANTY for this software, express or + * implied, including the implied warranties of MERCHANTABILITY or FITNESS + * FOR A PARTICULAR PURPOSE. You should have received a copy of GPLv2 + * along with this software; if not, see + * http://www.gnu.org/licenses/old-licenses/gpl-2.0.txt. + * + * Red Hat trademarks are not licensed under GPLv2. No permission is + * granted to use or replicate Red Hat trademarks that are incorporated + * in this software or its documentation. + */ + +package com.redhat.rhn.common; + +import java.io.Serializable; +import java.util.List; + +/** + * Represents a RHN general exception + */ +public class RhnGeneralException extends RuntimeException implements Serializable { + private final List errors; + + /** + * Constructor with a list of errors + * @param errorsIn the list of errors + */ + public RhnGeneralException(List errorsIn) { + this.errors = errorsIn; + } + + public List getErrors() { + return errors; + } + + /** + * Returns all error messages as a string array + * @return String array of error messages + */ + public String[] getErrorMessages() { + return errors.stream().map(RhnError::getMessage).toList().toArray(new String[0]); + } + +} diff --git a/java/code/src/com/redhat/rhn/common/RhnReportStrategy.java b/java/code/src/com/redhat/rhn/common/RhnReportStrategy.java new file mode 100644 index 000000000000..496a0ea97c16 --- /dev/null +++ b/java/code/src/com/redhat/rhn/common/RhnReportStrategy.java @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2025 SUSE LLC + * + * This software is licensed to you under the GNU General Public License, + * version 2 (GPLv2). There is NO WARRANTY for this software, express or + * implied, including the implied warranties of MERCHANTABILITY or FITNESS + * FOR A PARTICULAR PURPOSE. You should have received a copy of GPLv2 + * along with this software; if not, see + * http://www.gnu.org/licenses/old-licenses/gpl-2.0.txt. + * + * Red Hat trademarks are not licensed under GPLv2. No permission is + * granted to use or replicate Red Hat trademarks that are incorporated + * in this software or its documentation. + */ +package com.redhat.rhn.common; + +import java.util.List; + +public interface RhnReportStrategy { + + /** + * Report a list of errors + * @param errors the list of errors + */ + void report(List errors); + +} diff --git a/java/code/src/com/redhat/rhn/common/db/datasource/xml/System_queries.xml b/java/code/src/com/redhat/rhn/common/db/datasource/xml/System_queries.xml index 970aa1752818..b532770c2f51 100644 --- a/java/code/src/com/redhat/rhn/common/db/datasource/xml/System_queries.xml +++ b/java/code/src/com/redhat/rhn/common/db/datasource/xml/System_queries.xml @@ -1478,6 +1478,7 @@ SELECT 1 WHEN 'monitoring_entitled' then 'Monitoring' WHEN 'ansible_control_node' then 'Ansible Control Node' WHEN 'peripheral_server' then 'Peripheral Server' + WHEN 'proxy_entitled' then 'Proxy' END) diff --git a/java/code/src/com/redhat/rhn/common/security/acl/Access.java b/java/code/src/com/redhat/rhn/common/security/acl/Access.java index 43a7baa17172..e65604c84af7 100644 --- a/java/code/src/com/redhat/rhn/common/security/acl/Access.java +++ b/java/code/src/com/redhat/rhn/common/security/acl/Access.java @@ -1,4 +1,5 @@ /* + * Copyright (c) 2015--2025 SUSE LLC * Copyright (c) 2009--2015 Red Hat, Inc. * * This software is licensed to you under the GNU General Public License, @@ -676,4 +677,29 @@ public boolean aclSystemHasModularChannels(Map ctx, String[] par return server.getChannels().stream().anyMatch(Channel::isModular); } + + /** + * Uses the sid param to decide if a system is a proxy + * @param ctx Context Map to pass in + * @param params Parameters to use (unused) + * @return true if a system is a proxy, false otherwise + */ + public boolean aclSystemIsConvertibleToProxy(Map ctx, String[] params) { + Long sid = getAsLong(ctx.get("sid")); + User user = (User) ctx.get("user"); + Server lookedUp = SystemManager.lookupByIdAndUser(sid, user); + + return lookedUp.isConvertibleToProxy(); + } + + /** + * Checks is server has a proxy entitlement + * + * @param ctx Context map to pass in. + * @param params Parameters to use to fetch from context. + * @return True if system has proxy entitlement, false otherwise. + */ + public boolean aclSystemHasProxyEntitlement(Map ctx, String[] params) { + return SystemManager.serverHasProxyEntitlement(getAsLong(ctx.get("sid"))); + } } diff --git a/java/code/src/com/redhat/rhn/domain/action/ActionFactory.java b/java/code/src/com/redhat/rhn/domain/action/ActionFactory.java index 55466e5bd889..d389c7a166c4 100644 --- a/java/code/src/com/redhat/rhn/domain/action/ActionFactory.java +++ b/java/code/src/com/redhat/rhn/domain/action/ActionFactory.java @@ -1,4 +1,5 @@ /* + * Copyright (c) 2017--2025 SUSE LLC * Copyright (c) 2009--2017 Red Hat, Inc. * * This software is licensed to you under the GNU General Public License, @@ -1234,8 +1235,15 @@ public static void delete(ServerAction serverAction) { lookupActionTypeByLabel("coco.attestation"); /** - * The constant representing appstreams changes action. + * The constant representing appstreams changes action. [ID:524] */ public static final ActionType TYPE_APPSTREAM_CONFIGURE = lookupActionTypeByLabel("appstreams.configure"); + + + /** + * The constant representing "Apply Proxy Configuration" [ID:525] + */ + public static final ActionType TYPE_PROXY_CONFIGURATION_APPLY = + lookupActionTypeByLabel("proxy_configuration.apply"); } diff --git a/java/code/src/com/redhat/rhn/domain/action/ProxyConfigurationApplyAction.java b/java/code/src/com/redhat/rhn/domain/action/ProxyConfigurationApplyAction.java new file mode 100644 index 000000000000..132722271b42 --- /dev/null +++ b/java/code/src/com/redhat/rhn/domain/action/ProxyConfigurationApplyAction.java @@ -0,0 +1,75 @@ +/* + * Copyright (c) 2025 SUSE LLC + * Copyright (c) 2009--2010 Red Hat, Inc. + * + * This software is licensed to you under the GNU General Public License, + * version 2 (GPLv2). There is NO WARRANTY for this software, express or + * implied, including the implied warranties of MERCHANTABILITY or FITNESS + * FOR A PARTICULAR PURPOSE. You should have received a copy of GPLv2 + * along with this software; if not, see + * http://www.gnu.org/licenses/old-licenses/gpl-2.0.txt. + * + * Red Hat trademarks are not licensed under GPLv2. No permission is + * granted to use or replicate Red Hat trademarks that are incorporated + * in this software or its documentation. + */ +package com.redhat.rhn.domain.action; + + +import com.redhat.rhn.domain.server.MinionSummary; +import com.redhat.rhn.domain.server.Pillar; + +import com.suse.manager.webui.services.SaltServerActionService; +import com.suse.proxy.ProxyConfigUtils; +import com.suse.salt.netapi.calls.LocalCall; +import com.suse.salt.netapi.calls.modules.State; + +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +/** + * ProxyConfigurationApply - Class representing TYPE_PROXY_CONFIGURATION_APPLY action + */ +public class ProxyConfigurationApplyAction extends Action { + + private final Pillar pillar; + private final Map proxyConfigFiles; + + /** + * Default constructor + * @param pillarIn the pillar + * @param proxyConfigFilesIn the proxy configuration files + */ + public ProxyConfigurationApplyAction(Pillar pillarIn, Map proxyConfigFilesIn) { + this.setActionType(ActionFactory.TYPE_PROXY_CONFIGURATION_APPLY); + this.pillar = pillarIn; + this.proxyConfigFiles = proxyConfigFilesIn; + } + + public Pillar getPillar() { + return pillar; + } + + public Map getProxyConfigFiles() { + return proxyConfigFiles; + } + + /** + * Get the apply_proxy_config local call + * @param minions the minions + * @return the apply_proxy_config local call + */ + public Map, List> getApplyProxyConfigAction(List minions) { + Map data = new HashMap<>(); + data.putAll(ProxyConfigUtils.applyProxyConfigDataFromPillar(getPillar())); + data.putAll(getProxyConfigFiles()); + + return Map.of( + State.apply(Collections.singletonList(SaltServerActionService.APPLY_PROXY_CONFIG), Optional.of(data)), + minions + ); + } +} diff --git a/java/code/src/com/redhat/rhn/domain/entitlement/ProxyEntitlement.java b/java/code/src/com/redhat/rhn/domain/entitlement/ProxyEntitlement.java new file mode 100644 index 000000000000..3e6a813f7989 --- /dev/null +++ b/java/code/src/com/redhat/rhn/domain/entitlement/ProxyEntitlement.java @@ -0,0 +1,71 @@ +/* + * Copyright (c) 2025 SUSE LLC + * + * This software is licensed to you under the GNU General Public License, + * version 2 (GPLv2). There is NO WARRANTY for this software, express or + * implied, including the implied warranties of MERCHANTABILITY or FITNESS + * FOR A PARTICULAR PURPOSE. You should have received a copy of GPLv2 + * along with this software; if not, see + * http://www.gnu.org/licenses/old-licenses/gpl-2.0.txt. + * + * Red Hat trademarks are not licensed under GPLv2. No permission is + * granted to use or replicate Red Hat trademarks that are incorporated + * in this software or its documentation. + */ + +package com.redhat.rhn.domain.entitlement; + +import com.redhat.rhn.domain.server.Server; +import com.redhat.rhn.manager.entitlement.EntitlementManager; + +import com.suse.manager.reactor.utils.ValueMap; + +/** + * OS Image build host entitlement + */ +public class ProxyEntitlement extends Entitlement { + + /** + * Constructor + */ + public ProxyEntitlement() { + super(EntitlementManager.PROXY_ENTITLED); + } + + ProxyEntitlement(String labelIn) { + super(labelIn); + } + + /** + * {@inheritDoc} + */ + @Override + public boolean isPermanent() { + return false; + } + + /** + * {@inheritDoc} + */ + @Override + public boolean isBase() { + return false; + } + + /** + * {@inheritDoc} + */ + @Override + public boolean isAllowedOnServer(Server server) { + return super.isAllowedOnServer(server) && + server.getBaseEntitlement() instanceof SaltEntitlement; + } + + /** + * {@inheritDoc} + */ + @Override + public boolean isAllowedOnServer(Server server, ValueMap grains) { + return isAllowedOnServer(server); + } +} diff --git a/java/code/src/com/redhat/rhn/domain/server/Server.java b/java/code/src/com/redhat/rhn/domain/server/Server.java index 18e700a3d0ba..0a71aaf24f3a 100644 --- a/java/code/src/com/redhat/rhn/domain/server/Server.java +++ b/java/code/src/com/redhat/rhn/domain/server/Server.java @@ -1,4 +1,5 @@ /* + * Copyright (c) 2016--2025 SUSE LLC * Copyright (c) 2009--2015 Red Hat, Inc. * * This software is licensed to you under the GNU General Public License, @@ -2667,4 +2668,20 @@ public void setAppStreams(Set appStreamsIn) { public boolean hasAppStreamModuleEnabled(String module, String stream) { return getAppStreams().stream().anyMatch(it -> it.getName().equals(module) && it.getStream().equals(stream)); } + + /** + * Checks if a server in convertible to a proxy. + * @return true if the server is convertible to a proxy, false otherwise + */ + public boolean isConvertibleToProxy() { + return !isProxy() && (ConfigDefaults.get().isUyuni() || isSLEMicro5()); + } + + /** + * Checks if a server is a proxy. + * @return true if the server is a proxy, false otherwise + */ + public boolean hasProxyEntitlement() { + return hasEntitlement(EntitlementManager.PROXY); + } } diff --git a/java/code/src/com/redhat/rhn/domain/server/ServerConstants.java b/java/code/src/com/redhat/rhn/domain/server/ServerConstants.java index f8a10d5d06d6..520f290bd898 100644 --- a/java/code/src/com/redhat/rhn/domain/server/ServerConstants.java +++ b/java/code/src/com/redhat/rhn/domain/server/ServerConstants.java @@ -1,6 +1,6 @@ /* + * Copyright (c) 2013--2025 SUSE LLC * Copyright (c) 2009--2012 Red Hat, Inc. - * Copyright (c) 2013--2021 SUSE LLC * * This software is licensed to you under the GNU General Public License, * version 2 (GPLv2). There is NO WARRANTY for this software, express or @@ -123,4 +123,12 @@ public static final ServerGroupType getServerGroupTypeOSImageBuildHostEntitled() public static final ServerGroupType getServerGroupTypePeripheralServerEntitled() { return ServerFactory.lookupServerGroupTypeByLabel("peripheral_server"); } + + /** + * Static representing the Proxy entitled server group type + * @return ServerGroupType + */ + public static final ServerGroupType getServerGroupTypeProxyEntitled() { + return ServerFactory.lookupServerGroupTypeByLabel("proxy_entitled"); + } } diff --git a/java/code/src/com/redhat/rhn/frontend/strings/java/StringResource_en_US.xml b/java/code/src/com/redhat/rhn/frontend/strings/java/StringResource_en_US.xml index 196ea84874ea..7cbb989eea5a 100644 --- a/java/code/src/com/redhat/rhn/frontend/strings/java/StringResource_en_US.xml +++ b/java/code/src/com/redhat/rhn/frontend/strings/java/StringResource_en_US.xml @@ -2175,6 +2175,12 @@ and try again. /rhn/kickstart/cobbler/CobblerSnippetEdit + + Convert to Proxy + + System Details Convert to Proxy + + New systems can be autoinstalled based upon their ip address by appending <code>{0}</code> to the kernel parameters for an autoinstallation. @@ -9097,6 +9103,12 @@ Alternatively, you will want to download <strong>Incremental Channel Conte /rhn/systems/details/Overview.do + + <strong>Proxy</strong> type has been applied.<br/><strong>Note:</strong> This action will <em>not</em> result in state application. To apply the state, either use the <a href="/rhn/manager/systems/details/highstate?sid={0}">states page</a> or run <code class="text-info">state.highstate</code> from the command line. + + /rhn/systems/details/Overview.do + + <strong>OS Image Build Host</strong> type has been applied.<br/><strong>Note:</strong> This action will <em>not</em> result in state application. To apply the state, either use the <a href="/rhn/manager/systems/details/highstate?sid={0}">states page</a> or run <code class="text-info">state.highstate</code> from the command line. diff --git a/java/code/src/com/redhat/rhn/frontend/strings/jsp/StringResource_en_US.xml b/java/code/src/com/redhat/rhn/frontend/strings/jsp/StringResource_en_US.xml index 6ccc23095e16..c4e4c4aa1bf8 100644 --- a/java/code/src/com/redhat/rhn/frontend/strings/jsp/StringResource_en_US.xml +++ b/java/code/src/com/redhat/rhn/frontend/strings/jsp/StringResource_en_US.xml @@ -25148,6 +25148,9 @@ given channel. Peripheral Server + + Proxy + Formulas diff --git a/java/code/src/com/redhat/rhn/frontend/taglibs/IconTag.java b/java/code/src/com/redhat/rhn/frontend/taglibs/IconTag.java index 28b4bc631130..6b2230dcac0c 100644 --- a/java/code/src/com/redhat/rhn/frontend/taglibs/IconTag.java +++ b/java/code/src/com/redhat/rhn/frontend/taglibs/IconTag.java @@ -1,4 +1,5 @@ /* + * Copyright (c) 2019--2025 SUSE LLC * Copyright (c) 2013--2018 Red Hat, Inc. * * This software is licensed to you under the GNU General Public License, @@ -129,6 +130,7 @@ public class IconTag extends TagSupport { icons.put("item-edit", "fa fa-edit"); icons.put("item-enabled", "fa fa-check text-success"); icons.put("item-import", "fa fa-level-down"); + icons.put("item-proxy-convert", "fa fa-arrow-up"); icons.put("item-search", "fa fa-eye"); icons.put("item-ssm-add", "fa fa-plus-circle"); icons.put("item-ssm-del", "fa fa-minus-circle"); diff --git a/java/code/src/com/redhat/rhn/frontend/taglibs/ToolbarTag.java b/java/code/src/com/redhat/rhn/frontend/taglibs/ToolbarTag.java index d20e84070b8f..ac1a9f61fb91 100644 --- a/java/code/src/com/redhat/rhn/frontend/taglibs/ToolbarTag.java +++ b/java/code/src/com/redhat/rhn/frontend/taglibs/ToolbarTag.java @@ -1,4 +1,5 @@ /* + * Copyright (c) 2015--2025 SUSE LLC * Copyright (c) 2009--2014 Red Hat, Inc. * * This software is licensed to you under the GNU General Public License, @@ -41,7 +42,7 @@ *
  • altImg - alt text for the img *
  • helpUrl - link to the help pages. * - *

    + *

    * Action Attributes:
    *

      *
    • create button @@ -95,6 +96,8 @@ public class ToolbarTag extends TagSupport { private String deletionUrl; private String deletionAcl; private String deletionType; + private String convertProxyAcl; + private String convertProxyUrl; private HtmlTag baseTag; private HtmlTag toolbarDivTag; private HtmlTag headerTag; @@ -118,6 +121,7 @@ private void assertBase() { /** * Sets the required base HTML tag used to surround the toolbar. + * * @param b valid html tag. */ public void setBase(String b) { @@ -126,6 +130,7 @@ public void setBase(String b) { /** * Returns the required base HTML tag used to surround the toolbar. + * * @return the required base HTML tag used to surround the toolbar. */ public String getBase() { @@ -135,6 +140,7 @@ public String getBase() { /** * Sets the help url which is used to link to the help pages. + * * @param helpurl the help url which is used to link to the help pages. */ public void setHelpUrl(String helpurl) { @@ -143,6 +149,7 @@ public void setHelpUrl(String helpurl) { /** * Returns the help url which is used to link to the help pages. + * * @return the help url which is used to link to the help pages. */ public String getHelpUrl() { @@ -152,6 +159,7 @@ public String getHelpUrl() { /** * Sets the Acl classnames to be mixed in. The mixins * are applied in addition to the other acls. + * * @param mixins A comma separated list of Acl classnames. * @see ToolbarTag#getCreationAcl() * @see ToolbarTag#getDeletionAcl() @@ -173,6 +181,7 @@ public String getAclMixins() { /** * Sets the icon (css class name) which is displayed. + * * @param iconId Icon's id (usually the css class) */ public void setIcon(String iconId) { @@ -181,6 +190,7 @@ public void setIcon(String iconId) { /** * Getter for the icon id + * * @return the icon id to be displayed. */ public String getIcon() { @@ -196,7 +206,7 @@ public String getIconAlt() { /** * @param iconAltIn Sets the localization key that will resolve - * to the alt text of the icon + * to the alt text of the icon */ public void setIconAlt(String iconAltIn) { this.iconAlt = iconAltIn; @@ -204,6 +214,7 @@ public void setIconAlt(String iconAltIn) { /** * Sets the image location which is displayed. + * * @param imgurl the location of the image. */ public void setImg(String imgurl) { @@ -212,6 +223,7 @@ public void setImg(String imgurl) { /** * Returns the image location to be displayed. + * * @return the image location to be displayed. */ public String getImg() { @@ -227,7 +239,7 @@ public String getImgAlt() { /** * @param imgAltIn Sets the localization key that will resolve - * to the alt text of the img + * to the alt text of the img */ public void setImgAlt(String imgAltIn) { this.imgAlt = imgAltIn; @@ -235,6 +247,7 @@ public void setImgAlt(String imgAltIn) { /** * Sets the image used for the misc link. + * * @param miscimg URL to image file. */ public void setMiscImg(String miscimg) { @@ -243,6 +256,7 @@ public void setMiscImg(String miscimg) { /** * Returns the url for the misc image file. + * * @return the url for the misc image file. */ public String getMiscImg() { @@ -251,6 +265,7 @@ public String getMiscImg() { /** * Sets the misc icon (css class name) which is displayed. + * * @param iconId Misc Icon's id (usually the css class) */ public void setMiscIcon(String iconId) { @@ -259,6 +274,7 @@ public void setMiscIcon(String iconId) { /** * Getter for the icon id + * * @return the icon id to be displayed. */ public String getMiscIcon() { @@ -268,6 +284,7 @@ public String getMiscIcon() { /** * Sets the deletion type to be acted upon. + * * @param deltype the deletion type to be acted upon. */ public void setDeletionType(String deltype) { @@ -276,6 +293,7 @@ public void setDeletionType(String deltype) { /** * Returns the deletion type to be acted upon. + * * @return the deletion type to be acted upon. */ public String getDeletionType() { @@ -284,8 +302,9 @@ public String getDeletionType() { /** * Sets the acl used to control access to the deletion action button. + * * @param delacl the acl used to control access to the deletion action - * button. + * button. */ public void setDeletionAcl(String delacl) { deletionAcl = delacl; @@ -293,6 +312,7 @@ public void setDeletionAcl(String delacl) { /** * Returns the acl used to control access to the deletion action button. + * * @return the acl used to control access to the deletion action button. */ public String getDeletionAcl() { @@ -301,6 +321,7 @@ public String getDeletionAcl() { /** * Sets the url pointed by the deletion action button. + * * @param delurl the url pointed by the deletion action button. */ public void setDeletionUrl(String delurl) { @@ -309,14 +330,52 @@ public void setDeletionUrl(String delurl) { /** * Returns the url pointed by the deletion action button. + * * @return the url pointed by the deletion action button. */ public String getDeletionUrl() { return deletionUrl; } + /** + * Returns the url pointed by the convert to proxy action button. + * + * @return the url pointed by the convert to proxy action button. + */ + public String getConvertProxyUrl() { + return convertProxyUrl; + } + + /** + * Sets the convert proxy url. + * + * @param convertProxyUrlIn the convert proxy url. + */ + public void setConvertProxyUrl(String convertProxyUrlIn) { + convertProxyUrl = convertProxyUrlIn; + } + + /** + * Returns the convert proxy acl. + * + * @return the convert proxy acl. + */ + public String getConvertProxyAcl() { + return convertProxyAcl; + } + + /** + * Sets acl used to control access to the convert to proxy action button. + * + * @param convertProxyAclIn the acl used to control access to the convert to proxy action button. + */ + public void setConvertProxyAcl(String convertProxyAclIn) { + convertProxyAcl = convertProxyAclIn; + } + /** * Sets the creation type to be acted upon. + * * @param createtype the creation type to be acted upon. */ public void setCreationType(String createtype) { @@ -325,6 +384,7 @@ public void setCreationType(String createtype) { /** * Returns the creation type to be acted upon. + * * @return the creation type to be acted upon. */ public String getCreationType() { @@ -333,8 +393,9 @@ public String getCreationType() { /** * Sets the acl used to control access to the creation action button. + * * @param createacl the acl used to control access to the creation - * action button. + * action button. */ public void setCreationAcl(String createacl) { creationAcl = createacl; @@ -342,6 +403,7 @@ public void setCreationAcl(String createacl) { /** * Returns the acl used to control access to the creation action button. + * * @return the acl used to control access to the creation action button. */ public String getCreationAcl() { @@ -350,6 +412,7 @@ public String getCreationAcl() { /** * Sets the url pointed by the creation action button. + * * @param createurl the url pointed by the creation action button. */ public void setCreationUrl(String createurl) { @@ -358,6 +421,7 @@ public void setCreationUrl(String createurl) { /** * Returns the url pointed by the creation action button. + * * @return the url pointed by the creation action button. */ public String getCreationUrl() { @@ -367,6 +431,7 @@ public String getCreationUrl() { /** * Sets the clone type to be acted upon. + * * @param clonetype the creation type to be acted upon. */ public void setCloneType(String clonetype) { @@ -375,6 +440,7 @@ public void setCloneType(String clonetype) { /** * Returns the clone type to be acted upon. + * * @return the clone type to be acted upon. */ public String getCloneType() { @@ -383,8 +449,9 @@ public String getCloneType() { /** * Sets the acl used to control access to the clone action button. + * * @param cloneacl the acl used to control access to the clone - * action button. + * action button. */ public void setCloneAcl(String cloneacl) { cloneAcl = cloneacl; @@ -392,6 +459,7 @@ public void setCloneAcl(String cloneacl) { /** * Returns the acl used to control access to the clone action button. + * * @return the acl used to control access to the clone action button. */ public String getCloneAcl() { @@ -400,6 +468,7 @@ public String getCloneAcl() { /** * Sets the url pointed by the clone action button. + * * @param cloneurl the url pointed by the clone action button. */ public void setCloneUrl(String cloneurl) { @@ -408,6 +477,7 @@ public void setCloneUrl(String cloneurl) { /** * Returns the url pointed by the clone action button. + * * @return the url pointed by the clone action button. */ public String getCloneUrl() { @@ -417,6 +487,7 @@ public String getCloneUrl() { /** * Sets the acl used to control access to the miscellaneous link. + * * @param miscacl the acl used to control access to the miscellaneous link. */ public void setMiscAcl(String miscacl) { @@ -425,6 +496,7 @@ public void setMiscAcl(String miscacl) { /** * Returns the acl used to control access to the miscellaneous link. + * * @return the acl used to control access to the miscellaneous link. */ public String getMiscAcl() { @@ -433,6 +505,7 @@ public String getMiscAcl() { /** * Sets the url pointed by the miscellaneous link. + * * @param miscurl url for the miscellaneous link. */ public void setMiscUrl(String miscurl) { @@ -441,6 +514,7 @@ public void setMiscUrl(String miscurl) { /** * Returns the url pointed by the miscellaneous link. + * * @return the url pointed by the miscellaneous link. */ public String getMiscUrl() { @@ -449,6 +523,7 @@ public String getMiscUrl() { /** * Sets the alternate text for the miscellaneous link. + * * @param alt alternate text for the miscellaneous link. */ public void setMiscAlt(String alt) { @@ -457,6 +532,7 @@ public void setMiscAlt(String alt) { /** * Returns the alternate text for the miscellaneous link. + * * @return the alternate text for the miscellaneous link. */ public String getMiscAlt() { @@ -465,6 +541,7 @@ public String getMiscAlt() { /** * Sets the text for the miscellaneous link. + * * @param text text for the miscellaneous link. */ public void setMiscText(String text) { @@ -473,6 +550,7 @@ public void setMiscText(String text) { /** * Returns the text for the miscellaneous link. + * * @return the text for the miscellaneous link. */ public String getMiscText() { @@ -489,6 +567,7 @@ public void setMiscSpaOff(boolean isMiscSpaOff) { /** * {@inheritDoc} + * * @throws JspException JSP exception */ @Override @@ -509,6 +588,7 @@ public int doStartTag() throws JspException { buf.append(renderCloneLink()); buf.append(renderUploadLink()); + buf.append(renderConvertToProxyLink()); buf.append(renderDeletionLink()); buf.append(renderMiscLink()); buf.append(renderCreationLink()); @@ -574,7 +654,7 @@ private String renderImgUrl() { if (imgAlt != null) { tag.setAttribute("alt", LocalizationService.getInstance(). - getMessage(imgAlt)); + getMessage(imgAlt)); } return tag.render(); } @@ -596,7 +676,7 @@ private String renderCreationLink() { String create = "toolbar.create." + getCreationType(); return renderActionLink(getCreationUrl(), create, "btn btn-primary", - create, "item-add", null, false); + create, "item-add", null, false); } return ""; } @@ -607,7 +687,7 @@ private String renderCloneLink() { String clone = "toolbar.clone." + getCloneType(); return renderActionLink(getCloneUrl(), clone, "btn btn-default", - clone, "item-clone", null, false); + clone, "item-clone", null, false); } return ""; } @@ -622,6 +702,15 @@ private String renderDeletionLink() { return ""; } + private String renderConvertToProxyLink() { + if (evalAcl(getConvertProxyAcl()) && assertNotEmpty(getConvertProxyUrl())) { + + String convertProxy = "toolbar.convert.proxy"; + return renderActionLink(getConvertProxyUrl(), convertProxy, "btn btn-default", convertProxy, "item-proxy-convert", null, false); + } + return ""; + } + private String renderUploadLink() { if (evalAcl(getUploadAcl()) && assertNotEmpty(getUploadType()) && assertNotEmpty(getUploadUrl())) { @@ -645,7 +734,7 @@ private String renderMiscLink() { } return renderActionLink(getMiscUrl(), getMiscText(), "btn btn-default", - getMiscAlt(), getMiscIcon(), getMiscImg(), isMiscSpaOff()); + getMiscAlt(), getMiscIcon(), getMiscImg(), isMiscSpaOff()); } private String renderActionLink(String url, String text, String className, diff --git a/java/code/src/com/redhat/rhn/frontend/taglibs/rhn-taglib.tld b/java/code/src/com/redhat/rhn/frontend/taglibs/rhn-taglib.tld index 3ced174fd206..37d6fab4e287 100644 --- a/java/code/src/com/redhat/rhn/frontend/taglibs/rhn-taglib.tld +++ b/java/code/src/com/redhat/rhn/frontend/taglibs/rhn-taglib.tld @@ -296,6 +296,16 @@ deletionType false + + convertProxyAcl + false + true + + + convertProxyUrl + false + true + miscAlt false diff --git a/java/code/src/com/redhat/rhn/frontend/xmlrpc/activationkey/ActivationKeyHandler.java b/java/code/src/com/redhat/rhn/frontend/xmlrpc/activationkey/ActivationKeyHandler.java index 78f39d1b8297..a79de49ac384 100644 --- a/java/code/src/com/redhat/rhn/frontend/xmlrpc/activationkey/ActivationKeyHandler.java +++ b/java/code/src/com/redhat/rhn/frontend/xmlrpc/activationkey/ActivationKeyHandler.java @@ -1,4 +1,5 @@ /* + * Copyright (c) 2015--2025 SUSE LLC * Copyright (c) 2009--2014 Red Hat, Inc. * * This software is licensed to you under the GNU General Public License, @@ -136,6 +137,7 @@ public ActivationKeyHandler(ServerGroupManager serverGroupManagerIn) { * #item("osimage_build_host") * #item("virtualization_host") * #item("ansible_control_node") + * #item("proxy_entitled") * #options_end() * @apidoc.param #param("boolean", "universalDefault") * @apidoc.returntype #param("string", "The new activation key") @@ -531,6 +533,7 @@ public ActivationKey getDetails(User loggedInUser, String key) { * #item("osimage_build_host") * #item("virtualization_host") * #item("ansible_control_node") + * #item("proxy_entitled") * #options_end() * @apidoc.returntype #return_int_success() */ diff --git a/java/code/src/com/redhat/rhn/manager/entitlement/EntitlementManager.java b/java/code/src/com/redhat/rhn/manager/entitlement/EntitlementManager.java index 49662b19e2f4..9193d9e72711 100644 --- a/java/code/src/com/redhat/rhn/manager/entitlement/EntitlementManager.java +++ b/java/code/src/com/redhat/rhn/manager/entitlement/EntitlementManager.java @@ -1,4 +1,5 @@ /* + * Copyright (c) 2016--2025 SUSE LLC * Copyright (c) 2009--2015 Red Hat, Inc. * * This software is licensed to you under the GNU General Public License, @@ -23,6 +24,7 @@ import com.redhat.rhn.domain.entitlement.MonitoringEntitlement; import com.redhat.rhn.domain.entitlement.OSImageBuildHostEntitlement; import com.redhat.rhn.domain.entitlement.PeripheralServerEntitlement; +import com.redhat.rhn.domain.entitlement.ProxyEntitlement; import com.redhat.rhn.domain.entitlement.SaltEntitlement; import com.redhat.rhn.domain.entitlement.VirtualizationEntitlement; import com.redhat.rhn.manager.BaseManager; @@ -54,6 +56,7 @@ public class EntitlementManager extends BaseManager { public static final Entitlement MONITORING = new MonitoringEntitlement(); public static final Entitlement ANSIBLE_CONTROL_NODE = new AnsibleControlNodeEntitlement(); public static final Entitlement PERIPHERAL_SERVER = new PeripheralServerEntitlement(); + public static final Entitlement PROXY = new ProxyEntitlement(); public static final String UNENTITLED = "unentitled"; public static final String ENTERPRISE_ENTITLED = "enterprise_entitled"; @@ -66,6 +69,7 @@ public class EntitlementManager extends BaseManager { public static final String MONITORING_ENTITLED = "monitoring_entitled"; public static final String ANSIBLE_CONTROL_NODE_ENTITLED = "ansible_control_node"; public static final String PERIPHERAL_SERVER_ENTITLED = "peripheral_server"; + public static final String PROXY_ENTITLED = "proxy_entitled"; private static final Set ADDON_ENTITLEMENTS; private static final Set BASE_ENTITLEMENTS; @@ -77,6 +81,7 @@ public class EntitlementManager extends BaseManager { ADDON_ENTITLEMENTS.add(MONITORING); ADDON_ENTITLEMENTS.add(ANSIBLE_CONTROL_NODE); ADDON_ENTITLEMENTS.add(PERIPHERAL_SERVER); + ADDON_ENTITLEMENTS.add(PROXY); BASE_ENTITLEMENTS = new LinkedHashSet<>(); BASE_ENTITLEMENTS.add(MANAGEMENT); @@ -121,6 +126,9 @@ else if (ANSIBLE_CONTROL_NODE_ENTITLED.equals(name)) { else if (PERIPHERAL_SERVER_ENTITLED.equals(name)) { return PERIPHERAL_SERVER; } + else if (PROXY_ENTITLED.equals(name)) { + return PROXY; + } LOG.debug("Unknown entitlement: {}", name); return null; } diff --git a/java/code/src/com/redhat/rhn/manager/system/SystemManager.java b/java/code/src/com/redhat/rhn/manager/system/SystemManager.java index 5f936bbe461b..2b3f65378b0d 100644 --- a/java/code/src/com/redhat/rhn/manager/system/SystemManager.java +++ b/java/code/src/com/redhat/rhn/manager/system/SystemManager.java @@ -1,6 +1,6 @@ /* + * Copyright (c) 2019--2025 SUSE LLC * Copyright (c) 2009--2018 Red Hat, Inc. - * Copyright (c) 2024 SUSE LLC * * This software is licensed to you under the GNU General Public License, * version 2 (GPLv2). There is NO WARRANTY for this software, express or @@ -2144,7 +2144,7 @@ public static void activateProxy(Server server, String version) * @param certData the data needed to generate the new proxy SSL certificate. * Can be omitted if proxyCertKey is provided * @param certManager the SSLCertManager to use - * @return the configuration file + * @return the tarball configuration file as a byte array */ public byte[] createProxyContainerConfig(User user, String proxyName, Integer proxyPort, String server, Long maxCache, String email, @@ -2159,6 +2159,41 @@ public byte[] createProxyContainerConfig(User user, String proxyName, Integer pr rootCA, intermediateCAs, proxyCertKey, caPair, caPassword, certData, certManager); } + + /** + * Create and provide proxy container configuration. + * + * @param user the current user + * @param proxyName the FQDN of the proxy + * @param proxyPort the SSH port the proxy listens on + * @param server the FQDN of the server the proxy uses + * @param maxCache the maximum memory cache size + * @param email the email of proxy admin + * @param rootCA root CA used to sign the SSL certificate in PEM format + * @param intermediateCAs intermediate CAs used to sign the SSL certificate in PEM format + * @param proxyCertKey proxy CRT and key pair + * @param caPair the CA certificate and key used to sign the certificate to generate. + * Can be omitted if proxyCertKey is provided + * @param caPassword the CA private key password. + * Can be omitted if proxyCertKey is provided + * @param certData the data needed to generate the new proxy SSL certificate. + * Can be omitted if proxyCertKey is provided + * @param certManager the SSLCertManager to use + * @return the configuration files as a map + */ + public Map createProxyContainerConfigFiles(User user, String proxyName, Integer proxyPort, String server, + Long maxCache, String email, + String rootCA, List intermediateCAs, + SSLCertPair proxyCertKey, + SSLCertPair caPair, String caPassword, SSLCertData certData, + SSLCertManager certManager) + throws SSLCertGenerationException { + + return new ProxyContainerConfigCreate().createFiles( + saltApi, systemEntitlementManager, user, server, proxyName, proxyPort, maxCache, email, + rootCA, intermediateCAs, proxyCertKey, caPair, caPassword, certData, certManager); + } + /** * Returns a DataResult containing the systems subscribed to a particular channel. * but returns a DataResult of SystemOverview objects instead of maps @@ -3914,4 +3949,17 @@ public static void updateSystemOverview(Server server) { updateSystemOverview(server.getId()); } } + + /** + * Return true the given server has bootstrap entitlement, + * false otherwise. + + * @param sid Server ID to lookup. + * @return true if the server has bootstrap entitlement, + * false otherwise. + */ + public static boolean serverHasProxyEntitlement(Long sid) { + Server s = ServerFactory.lookupById(sid); + return s.hasEntitlement(EntitlementManager.PROXY); + } } diff --git a/java/code/src/com/redhat/rhn/manager/system/proxycontainerconfig/ProxyContainerConfigCreate.java b/java/code/src/com/redhat/rhn/manager/system/proxycontainerconfig/ProxyContainerConfigCreate.java index 204b1d941477..0d866e8333d5 100644 --- a/java/code/src/com/redhat/rhn/manager/system/proxycontainerconfig/ProxyContainerConfigCreate.java +++ b/java/code/src/com/redhat/rhn/manager/system/proxycontainerconfig/ProxyContainerConfigCreate.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2024 SUSE LLC + * Copyright (c) 2024--2025 SUSE LLC * * This software is licensed to you under the GNU General Public License, * version 2 (GPLv2). There is NO WARRANTY for this software, express or @@ -26,7 +26,9 @@ import com.suse.manager.webui.services.iface.SaltApi; import java.util.ArrayList; +import java.util.HashMap; import java.util.List; +import java.util.Map; /** * Main handler for creating proxy container configuration files. @@ -77,7 +79,7 @@ public ProxyContainerConfigCreate() { * @param certData the data needed to generate the new proxy SSL certificate. * Can be omitted if proxyCertKey is not provided * @param certManager the SSLCertManager to use - * @return the configuration file + * @return the tarball configuration file as a byte array */ public byte[] create( SaltApi saltApi, SystemEntitlementManager systemEntitlementManager, User user, @@ -95,4 +97,50 @@ public byte[] create( return context.getConfigTar(); } + /** + * Create and provide proxy container configuration files. + * + * @param saltApi the Salt API instance + * @param systemEntitlementManager the system entitlement manager instance + * @param user the current user + * @param serverFqdn the FQDN of the server the proxy uses + * @param proxyFqdn the FQDN of the proxy + * @param proxyPort the SSH port the proxy listens on + * @param maxCache the maximum memory cache size + * @param email the email of proxy admin + * @param rootCA root CA used to sign the SSL certificate in PEM format + * @param intermediateCAs intermediate CAs used to sign the SSL certificate in PEM format + * @param proxyCertKey proxy CRT and key pair + * @param caPair the CA certificate and key used to sign the certificate to generate. + * Can be omitted if proxyCertKey is not provided + * @param caPassword the CA private key password. + * Can be omitted if proxyCertKey is not provided + * @param certData the data needed to generate the new proxy SSL certificate. + * Can be omitted if proxyCertKey is not provided + * @param certManager the SSLCertManager to use + * @return the configuration files as a map + */ + public Map createFiles( + SaltApi saltApi, SystemEntitlementManager systemEntitlementManager, User user, + String serverFqdn, String proxyFqdn, Integer proxyPort, Long maxCache, String email, + String rootCA, List intermediateCAs, SSLCertPair proxyCertKey, + SSLCertPair caPair, String caPassword, SSLCertData certData, SSLCertManager certManager + ) { + ProxyContainerConfigCreateContext context = new ProxyContainerConfigCreateContext( + saltApi, user, systemEntitlementManager, serverFqdn, proxyFqdn, proxyPort, maxCache, email, rootCA, + intermediateCAs, proxyCertKey, caPair, caPassword, certData, certManager + ); + + for (ProxyContainerConfigCreateContextHandler handler : contextHandlerChain) { + handler.handle(context); + } + + Map fileContents = new HashMap<>(); + fileContents.putAll(context.getConfigMap()); + fileContents.putAll(context.getHttpConfigMap()); + fileContents.putAll(context.getSshConfigMap()); + + return fileContents; + } + } diff --git a/java/code/src/com/suse/manager/webui/Router.java b/java/code/src/com/suse/manager/webui/Router.java index 50f918af716d..b546f8f6159b 100644 --- a/java/code/src/com/suse/manager/webui/Router.java +++ b/java/code/src/com/suse/manager/webui/Router.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2015 SUSE LLC + * Copyright (c) 2015--2025 SUSE LLC * * This software is licensed to you under the GNU General Public License, * version 2 (GPLv2). There is NO WARRANTY for this software, express or @@ -51,6 +51,7 @@ import com.suse.manager.webui.controllers.NotificationMessageController; import com.suse.manager.webui.controllers.PackageController; import com.suse.manager.webui.controllers.ProductsController; +import com.suse.manager.webui.controllers.ProxyConfigurationController; import com.suse.manager.webui.controllers.ProxyController; import com.suse.manager.webui.controllers.RecurringActionController; import com.suse.manager.webui.controllers.SSOController; @@ -135,6 +136,7 @@ public void init() { DownloadController downloadController = new DownloadController(paygManager); ConfidentialComputingController confidentialComputingController = new ConfidentialComputingController(attestationManager); + ProxyConfigurationController proxyConfigurationController = new ProxyConfigurationController(systemManager, saltApi); // Login LoginController.initRoutes(jade); @@ -178,6 +180,9 @@ public void init() { // Proxy proxyController.initRoutes(proxyController, jade); + // Proxy Configuration + proxyConfigurationController.initRoutes(proxyConfigurationController, jade); + //CSV API CSVDownloadController.initRoutes(); diff --git a/java/code/src/com/suse/manager/webui/controllers/ProxyConfigurationController.java b/java/code/src/com/suse/manager/webui/controllers/ProxyConfigurationController.java new file mode 100644 index 000000000000..ae3beb7b6c3b --- /dev/null +++ b/java/code/src/com/suse/manager/webui/controllers/ProxyConfigurationController.java @@ -0,0 +1,344 @@ +/* + * Copyright (c) 2025 SUSE LLC + * + * This software is licensed to you under the GNU General Public License, + * version 2 (GPLv2). There is NO WARRANTY for this software, express or + * implied, including the implied warranties of MERCHANTABILITY or FITNESS + * FOR A PARTICULAR PURPOSE. You should have received a copy of GPLv2 + * along with this software; if not, see + * http://www.gnu.org/licenses/old-licenses/gpl-2.0.txt. + * + * Red Hat trademarks are not licensed under GPLv2. No permission is + * granted to use or replicate Red Hat trademarks that are incorporated + * in this software or its documentation. + */ + +package com.suse.manager.webui.controllers; + +import static com.suse.manager.webui.utils.SparkApplicationHelper.badRequest; +import static com.suse.manager.webui.utils.SparkApplicationHelper.internalServerError; +import static com.suse.manager.webui.utils.SparkApplicationHelper.result; +import static com.suse.manager.webui.utils.SparkApplicationHelper.withCsrfToken; +import static com.suse.manager.webui.utils.SparkApplicationHelper.withDocsLocale; +import static com.suse.manager.webui.utils.SparkApplicationHelper.withUser; +import static com.suse.manager.webui.utils.SparkApplicationHelper.withUserAndServer; +import static com.suse.utils.Predicates.isAbsent; +import static spark.Spark.get; +import static spark.Spark.post; + +import com.redhat.rhn.GlobalInstanceHolder; +import com.redhat.rhn.common.RhnGeneralException; +import com.redhat.rhn.common.RhnRuntimeException; +import com.redhat.rhn.common.conf.Config; +import com.redhat.rhn.common.conf.ConfigDefaults; +import com.redhat.rhn.common.db.datasource.DataResult; +import com.redhat.rhn.common.validator.ValidatorResult; +import com.redhat.rhn.domain.server.Server; +import com.redhat.rhn.domain.server.ServerFactory; +import com.redhat.rhn.domain.user.User; +import com.redhat.rhn.frontend.dto.ShortSystemInfo; +import com.redhat.rhn.manager.entitlement.EntitlementManager; +import com.redhat.rhn.manager.system.SystemManager; +import com.redhat.rhn.manager.system.entitling.SystemEntitlementManager; + +import com.suse.manager.api.ParseException; +import com.suse.manager.reactor.utils.LocalDateTimeISOAdapter; +import com.suse.manager.reactor.utils.OptionalTypeAdapterFactory; +import com.suse.manager.utils.MinionServerUtils; +import com.suse.manager.webui.services.iface.SaltApi; +import com.suse.manager.webui.utils.gson.ProxyConfigUpdateJson; +import com.suse.manager.webui.utils.gson.ResultJson; +import com.suse.manager.webui.utils.gson.SimpleMinionJson; +import com.suse.proxy.ProxyConfigUtils; +import com.suse.proxy.ProxyContainerImagesEnum; +import com.suse.proxy.ProxyRegistryUtils; +import com.suse.proxy.RegistryUrl; +import com.suse.proxy.get.ProxyConfigGet; +import com.suse.proxy.update.ProxyConfigUpdate; +import com.suse.utils.Json; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.JsonObject; +import com.google.gson.reflect.TypeToken; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +import java.net.URISyntaxException; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +import spark.ModelAndView; +import spark.Request; +import spark.Response; +import spark.template.jade.JadeTemplateEngine; + +/** + * Controller class providing backend code for proxy configuration specific pages. + */ +public class ProxyConfigurationController { + + private static final Logger LOG = LogManager.getLogger(ProxyConfigurationController.class); + + public static final String IS_EXACT_TAG = "isExact"; + public static final String REGISTRY_URL_TAG = "registryUrl"; + + private final SystemManager systemManager; + private final SaltApi saltApi; + private SystemEntitlementManager systemEntitlementManager = GlobalInstanceHolder.SYSTEM_ENTITLEMENT_MANAGER; + + private static final Gson GSON = new GsonBuilder() + .registerTypeAdapter(LocalDateTime.class, new LocalDateTimeISOAdapter()) + .registerTypeAdapterFactory(new OptionalTypeAdapterFactory()) + .serializeNulls() + .create(); + + /** + * Create a new controller instance + * + * @param systemManagerIn the system manager + * @param saltApiIn the salt API + */ + public ProxyConfigurationController(SystemManager systemManagerIn, SaltApi saltApiIn) { + this.systemManager = systemManagerIn; + this.saltApi = saltApiIn; + } + + /** + * Invoked from Router. Initialize routes for Proxy Views. + * + * @param proxyController instance to register. + * @param jade Jade template engine + */ + public void initRoutes(ProxyConfigurationController proxyController, JadeTemplateEngine jade) { + get("/manager/systems/details/proxy-config", + withCsrfToken(withDocsLocale(withUserAndServer(this::proxyConfig))), + jade + ); + post("/manager/systems/details/proxy-config", withUser(this::updateProxyConfiguration)); + post("/manager/systems/details/proxy-config/registry-url", withUser(this::checkRegistryUrl)); + } + + /** + * Displays the form to create a new container-based proxy configuration + * + * @param request the request object + * @param response the response object + * @param user the current user + * @param server the current server + * @return the ModelAndView object to render the page + */ + public ModelAndView proxyConfig(Request request, Response response, User user, Server server) { + Map data = new HashMap<>(); + + // Handle the "Convert to Proxy" button: if server is convertible to proxy and doesn't have the proxy + // entitlement, add it + if (server.isConvertibleToProxy() && !server.hasProxyEntitlement()) { + user.getOrg().getValidAddOnEntitlementsForOrg().stream() + .filter(e -> e.getLabel().equalsIgnoreCase(EntitlementManager.PROXY_ENTITLED)) + .findFirst() + .ifPresentOrElse(f -> { + ValidatorResult vr = systemEntitlementManager.addEntitlementToServer(server, f); + if (!vr.getErrors().isEmpty()) { + LOG.error("Failed to add proxy entitlement to server. Server ID: {}, " + + "hasProxyEntitlement: {}, isConvertibleToProxy: {}, errors: {}", + server.getId(), + server.hasProxyEntitlement(), + server.isConvertibleToProxy(), + vr.getErrors() + ); + data.put("initFailMessage", "Failed to automatically add proxy entitlement to server."); + } + }, () -> { + }); + } + + Map proxyConfigDataMap = + ProxyConfigUtils.safeDataMapFromProxyConfig(new ProxyConfigGet().get(server)); + data.put("currentConfig", Json.GSON.toJson(proxyConfigDataMap)); + data.put("parents", Json.GSON.toJson(getElectableParents(user))); + data.put("isUyuni", ConfigDefaults.get().isUyuni()); + return new ModelAndView(data, "templates/minion/proxy-config.jade"); + } + + /** + * Get the list of electable parents to use as parent. + * + * @param user the current user + * @return the list of electable parents + */ + public List getElectableParents(User user) { + DataResult dr = SystemManager.systemListShort(user, null); + dr.elaborate(); + Set systems = Arrays.stream(dr.toArray()) + .map(system -> ((ShortSystemInfo) system).getId()). + collect(Collectors.toSet()); + + List electableParentsServers = + ServerFactory.lookupByIdsAndOrg(systems, user.getOrg()).stream().filter(Server::isProxy).toList(); + List electableParentsAsSimpleMinionJson = new ArrayList<>( + MinionServerUtils.filterSaltMinions(electableParentsServers) + .map(SimpleMinionJson::fromMinionServer) + .toList() + ); + + String localManagerFqdn = Config.get().getString(ConfigDefaults.SERVER_HOSTNAME); + if (isAbsent(localManagerFqdn)) { + if (LOG.isErrorEnabled()) { + LOG.error("Could not determine the Server FQDN. Skipping it as a parent."); + } + return electableParentsAsSimpleMinionJson; + } + + electableParentsAsSimpleMinionJson.add(new SimpleMinionJson(-1L, localManagerFqdn)); + return electableParentsAsSimpleMinionJson; + } + + /** + * Check a given registry URL and return associated tags (or an error message). + * The request is expected to contain a registry URL and a flag indicating if the URL is exact. + * + * @param request the request object + * @param response the response object + * @param user the user + * @return the tags or an error message + */ + public Object checkRegistryUrl(Request request, Response response, User user) { + try { + JsonObject jsonObject = new Gson().fromJson(request.body(), JsonObject.class); + String registryUrl = jsonObject.get(REGISTRY_URL_TAG).getAsString(); + + return jsonObject.has(IS_EXACT_TAG) && jsonObject.get(IS_EXACT_TAG).getAsBoolean() ? + getTagsFromRegistry(response, registryUrl) : + getCommonTagsFromRegistry(response, registryUrl); + } + catch (Exception e) { + LOG.error("Failed to check registry URL", e); + return result(response, ResultJson.error("Failed to check registry URL")); + } + } + + + /** + * Get the tags from the registry when the URL for a specific image. + * Eg: + * - https://registry.opensuse.org/uyuni/proxy-httpd + * + * @param response the response object + * @param registryUrlAsString the registry URL for a specific image + * @return the json with either the tags or with an error message + */ + public Object getTagsFromRegistry(Response response, String registryUrlAsString) { + try { + RegistryUrl registryUrl = new RegistryUrl(registryUrlAsString); + List tags = ProxyRegistryUtils.getTags(registryUrl); + if (tags == null) { + LOG.debug("No tags found on registry {}", registryUrlAsString); + return result(response, ResultJson.error("No tags found on registry")); + } + return result(response, ResultJson.success(tags)); + } + catch (Exception e) { + LOG.error("Failed downloading tags from registry {} {}", registryUrlAsString, e); + return result(response, ResultJson.error("Failed to download tags from registry")); + } + } + + /** + * Retrieves the common tags among the proxy images from the given base registry URL. + * + * @param response the response object + * @param baseRegistryUrl the base registry URL + * @return the json with either the list of common tags or with an error message + */ + private Object getCommonTagsFromRegistry(Response response, String baseRegistryUrl) + throws URISyntaxException, RhnRuntimeException, ParseException { + RegistryUrl registryUrl = new RegistryUrl(baseRegistryUrl); + + List repositories = ProxyRegistryUtils.getRepositories(registryUrl); + if (repositories.isEmpty()) { + LOG.debug("No repositories found on registry {}", baseRegistryUrl); + return result(response, ResultJson.error("No repositories found on registry")); + } + + // Check if all proxy images are present in the catalog + Set repositorySet = new HashSet<>(repositories); + Set proxyImageList = new HashSet<>(ProxyContainerImagesEnum.values().length); + String pathPrefix = registryUrl.getPath().substring(1); + for (ProxyContainerImagesEnum proxyImage : ProxyContainerImagesEnum.values()) { + proxyImageList.add(pathPrefix + "/" + proxyImage.getImageName()); + } + + if (!repositorySet.containsAll(proxyImageList)) { + return result(response, ResultJson.error("Cannot find all images in catalog")); + } + + // Collect common tags among proxy images + Set commonTags = null; + for (ProxyContainerImagesEnum proxyImage : ProxyContainerImagesEnum.values()) { + RegistryUrl imageRegistryUrl = new RegistryUrl(registryUrl.getUrl() + "/" + proxyImage.getImageName()); + List tags = ProxyRegistryUtils.getTags(imageRegistryUrl); + + if (tags == null || tags.isEmpty()) { + LOG.debug("No tags found on registry {}", imageRegistryUrl); + return result(response, ResultJson.error("No common tags found among proxy images")); + } + + Set tagSet = new HashSet<>(tags); + if (commonTags == null) { + commonTags = new HashSet<>(tagSet); + } + else { + commonTags.retainAll(tagSet); + if (commonTags.isEmpty()) { + break; + } + } + } + + if (isAbsent(commonTags)) { + LOG.debug("No common tags found among proxy images using registryUrl {}", baseRegistryUrl); + return result(response, ResultJson.error("No common tags found among proxy images")); + } + + List commonTagsList = new ArrayList<>(commonTags); + Collections.sort(commonTagsList); + return result(response, ResultJson.success(commonTagsList)); + } + + /** + * Convert a minion to a proxy. + * + * @param request the request object + * @param response the response object + * @param user the user + * @return the result of the conversion + */ + public String updateProxyConfiguration(Request request, Response response, User user) { + ProxyConfigUpdateJson data = + GSON.fromJson(request.body(), new TypeToken() { }.getType()); + + try { + new ProxyConfigUpdate().update(data, systemManager, saltApi, user); + return result(response, ResultJson.success("Proxy configuration applied")); + } + catch (RhnRuntimeException e) { + LOG.error("Failed to apply proxy configuration to minion", e); + return internalServerError(response, e.getMessage()); + } + catch (RhnGeneralException e) { + LOG.error("Failed to apply proxy configuration to minion", e); + return badRequest(response, e.getErrorMessages()); + } + + } +} diff --git a/java/code/src/com/suse/manager/webui/services/SaltServerActionService.java b/java/code/src/com/suse/manager/webui/services/SaltServerActionService.java index df29e6316e65..d9ffd44ec5c5 100644 --- a/java/code/src/com/suse/manager/webui/services/SaltServerActionService.java +++ b/java/code/src/com/suse/manager/webui/services/SaltServerActionService.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2016 SUSE LLC + * Copyright (c) 2016--2025 SUSE LLC * * This software is licensed to you under the GNU General Public License, * version 2 (GPLv2). There is NO WARRANTY for this software, express or @@ -44,6 +44,7 @@ import com.redhat.rhn.domain.action.ActionFactory; import com.redhat.rhn.domain.action.ActionStatus; import com.redhat.rhn.domain.action.ActionType; +import com.redhat.rhn.domain.action.ProxyConfigurationApplyAction; import com.redhat.rhn.domain.action.appstream.AppStreamAction; import com.redhat.rhn.domain.action.appstream.AppStreamActionDetails; import com.redhat.rhn.domain.action.channel.SubscribeChannelsAction; @@ -220,6 +221,7 @@ public class SaltServerActionService { public static final String APPSTREAMS_CONFIGURE = "appstreams.configure"; public static final String PARAM_APPSTREAMS_ENABLE = "param_appstreams_enable"; public static final String PARAM_APPSTREAMS_DISABLE = "param_appstreams_disable"; + public static final String APPLY_PROXY_CONFIG = "apply_proxy_config"; /** SLS pillar parameter name for the list of update stack patch names. */ public static final String PARAM_UPDATE_STACK_PATCHES = "param_update_stack_patches"; @@ -361,6 +363,9 @@ else if (ActionFactory.TYPE_COCO_ATTESTATION.equals(actionType)) { else if (ActionFactory.TYPE_APPSTREAM_CONFIGURE.equals(actionType)) { return appStreamAction(minions, (AppStreamAction) actionIn); } + else if (ActionFactory.TYPE_PROXY_CONFIGURATION_APPLY.equals(actionType)) { + return ((ProxyConfigurationApplyAction) actionIn).getApplyProxyConfigAction(minions); + } else { if (LOG.isDebugEnabled()) { LOG.debug("Action type {} is not supported with Salt", actionType != null ? actionType.getName() : ""); @@ -1891,8 +1896,8 @@ private Map> callAsyncActionChainStart( * @param action the action to be executed * @param minion minion on which the action will be executed */ - public void executeSSHAction(Action action, MinionServer minion) { - executeSSHAction(action, minion, false); + public Map, Optional> executeSSHAction(Action action, MinionServer minion) { + return executeSSHAction(action, minion, false); } /** @@ -1902,7 +1907,9 @@ public void executeSSHAction(Action action, MinionServer minion) { * @param minion minion on which the action will be executed * @param forcePkgRefresh set to true if a package list refresh should be scheduled at the end */ - public void executeSSHAction(Action action, MinionServer minion, boolean forcePkgRefresh) { + public Map, Optional> executeSSHAction(Action action, MinionServer minion, boolean forcePkgRefresh) { + Map, Optional> results = new HashMap<>(); + Optional serverAction = action.getServerActions().stream() .filter(sa -> sa.getServerId().equals(minion.getId())) .findFirst(); @@ -1993,6 +2000,7 @@ public void executeSSHAction(Action action, MinionServer minion, boolean forcePk sa.setCompletionTime(new Date()); } }, jsonResult -> { + results.put(call, Optional.of(jsonResult)); String function = (String) call.getPayload().get("fun"); /* bsc#1197591 ssh push reboot has an answer that is not a failure but the action needs to stay @@ -2030,9 +2038,11 @@ else if (sa.getStatus().equals(ActionFactory.STATUS_QUEUED)) { sa.setStatus(STATUS_FAILED); sa.setResultMsg("Minion is down or could not be contacted."); sa.setCompletionTime(new Date()); + results.put(call, Optional.empty()); }); } }); + return results; } /** diff --git a/java/code/src/com/suse/manager/webui/templates/minion/proxy-config.jade b/java/code/src/com/suse/manager/webui/templates/minion/proxy-config.jade new file mode 100644 index 000000000000..6df98f079032 --- /dev/null +++ b/java/code/src/com/suse/manager/webui/templates/minion/proxy-config.jade @@ -0,0 +1,21 @@ +include ./minion-header.jade + +#proxy-config + +script(type='text/javascript'). + window.csrfToken = "#{csrf_token}"; + +script(type='text/javascript'). + spaImportReactPage('minion/proxy/proxy-config') + .then(function(module) { + module.renderer( + 'proxy-config', + { + serverId: "#{server.id}", + isUyuni: JSON.parse("#{isUyuni}"), + parents: JSON.parse('!{parents}'), + currentConfig: !{currentConfig}, + initFailMessage: "#{initFailMessage}", + } + ) + }); diff --git a/java/code/src/com/suse/manager/webui/templates/system-common.jade b/java/code/src/com/suse/manager/webui/templates/system-common.jade index edb4b9d0f025..872c7c758d02 100644 --- a/java/code/src/com/suse/manager/webui/templates/system-common.jade +++ b/java/code/src/com/suse/manager/webui/templates/system-common.jade @@ -14,6 +14,10 @@ else .spacewalk-toolbar-h1 .spacewalk-toolbar + if !server.isConvertibleToProxy() + a(href="/rhn/manager/proxy/container-config/#{server.id}") + i.fa.fa-arrow-up(title='Convert to Proxy') + | #{l.t("Convert to Proxy")} a(href="/rhn/systems/details/DeleteConfirm.do?sid=#{server.id}") i.fa.fa-trash-o(title='Delete System') | #{l.t("Delete System")} diff --git a/java/code/src/com/suse/manager/webui/utils/SparkApplicationHelper.java b/java/code/src/com/suse/manager/webui/utils/SparkApplicationHelper.java index b16f1ac97f68..aed0ddc47e2a 100644 --- a/java/code/src/com/suse/manager/webui/utils/SparkApplicationHelper.java +++ b/java/code/src/com/suse/manager/webui/utils/SparkApplicationHelper.java @@ -721,6 +721,17 @@ public static String result(Response response, ResultJson result, TypeTok return GSON.toJson(result, parameterizedType); } + /** + * Serialize the result and set the response content type to JSON. + * @param response the http response + * @param result the object to serialize to JSON + * @param type of the result + * @return a JSON string + */ + public static String result(Response response, ResultJson result) { + return result(response, result, new TypeToken<>() { }); + } + /** * Serialize the result and set the response content type to JSON. * @param gson {@link Gson} object to use for serialization diff --git a/java/code/src/com/suse/manager/webui/utils/gson/ProxyConfigUpdateJson.java b/java/code/src/com/suse/manager/webui/utils/gson/ProxyConfigUpdateJson.java new file mode 100644 index 000000000000..31a41099fafc --- /dev/null +++ b/java/code/src/com/suse/manager/webui/utils/gson/ProxyConfigUpdateJson.java @@ -0,0 +1,157 @@ +/* + * Copyright (c) 2025 SUSE LLC + * + * This software is licensed to you under the GNU General Public License, + * version 2 (GPLv2). There is NO WARRANTY for this software, express or + * implied, including the implied warranties of MERCHANTABILITY or FITNESS + * FOR A PARTICULAR PURPOSE. You should have received a copy of GPLv2 + * along with this software; if not, see + * http://www.gnu.org/licenses/old-licenses/gpl-2.0.txt. + * + * Red Hat trademarks are not licensed under GPLv2. No permission is + * granted to use or replicate Red Hat trademarks that are incorporated + * in this software or its documentation. + */ +package com.suse.manager.webui.utils.gson; + +import com.google.gson.annotations.SerializedName; + +import java.util.List; + +/** + * Represents the data sent from the UI to convert a minion into a proxy container configuration + */ +public class ProxyConfigUpdateJson { + + private Long serverId; + + @SerializedName("parentFQDN") + private String parentFqdn; + + private Integer proxyPort; + + @SerializedName("maxSquidCacheSize") + private Integer maxCache; + + @SerializedName("proxyAdminEmail") + private String email; + + private String useCertsMode; + private String rootCA; + private List intermediateCAs; + @SerializedName("proxyCertificate") + private String proxyCert; + private String proxyKey; + + private String sourceMode; + private String registryMode; + private String registryBaseURL; + private String registryBaseTag; + private String registryHttpdURL; + private String registryHttpdTag; + private String registrySaltbrokerURL; + private String registrySaltbrokerTag; + private String registrySquidURL; + private String registrySquidTag; + private String registrySshURL; + private String registrySshTag; + private String registryTftpdURL; + private String registryTftpdTag; + + + public String getParentFqdn() { + return parentFqdn; + } + + public Integer getProxyPort() { + return proxyPort; + } + + public Integer getMaxCache() { + return maxCache; + } + + public String getEmail() { + return email; + } + + public String getRootCA() { + return rootCA; + } + + public List getIntermediateCAs() { + return intermediateCAs; + } + + public String getProxyCert() { + return proxyCert; + } + + public String getProxyKey() { + return proxyKey; + } + + public String getSourceMode() { + return sourceMode; + } + + public String getRegistryMode() { + return registryMode; + } + + public String getRegistryBaseURL() { + return registryBaseURL; + } + + public String getRegistryBaseTag() { + return registryBaseTag; + } + + public String getRegistryHttpdURL() { + return registryHttpdURL; + } + + public String getRegistryHttpdTag() { + return registryHttpdTag; + } + + public String getRegistrySaltbrokerURL() { + return registrySaltbrokerURL; + } + + public String getRegistrySaltbrokerTag() { + return registrySaltbrokerTag; + } + + public String getRegistrySquidURL() { + return registrySquidURL; + } + + public String getRegistrySquidTag() { + return registrySquidTag; + } + + public String getRegistrySshURL() { + return registrySshURL; + } + + public String getRegistrySshTag() { + return registrySshTag; + } + + public String getRegistryTftpdURL() { + return registryTftpdURL; + } + + public String getRegistryTftpdTag() { + return registryTftpdTag; + } + + public Long getServerId() { + return serverId; + } + + public String getUseCertsMode() { + return useCertsMode; + } +} diff --git a/java/code/src/com/suse/proxy/ProxyConfigUtils.java b/java/code/src/com/suse/proxy/ProxyConfigUtils.java new file mode 100644 index 000000000000..392a26ba7255 --- /dev/null +++ b/java/code/src/com/suse/proxy/ProxyConfigUtils.java @@ -0,0 +1,289 @@ +/* + * Copyright (c) 2025 SUSE LLC + * + * This software is licensed to you under the GNU General Public License, + * version 2 (GPLv2). There is NO WARRANTY for this software, express or + * implied, including the implied warranties of MERCHANTABILITY or FITNESS + * FOR A PARTICULAR PURPOSE. You should have received a copy of GPLv2 + * along with this software; if not, see + * http://www.gnu.org/licenses/old-licenses/gpl-2.0.txt. + * + * Red Hat trademarks are not licensed under GPLv2. No permission is + * granted to use or replicate Red Hat trademarks that are incorporated + * in this software or its documentation. + */ + +package com.suse.proxy; + +import static com.redhat.rhn.common.ExceptionMessage.NOT_INSTANTIABLE; +import static com.suse.proxy.ProxyContainerImagesEnum.PROXY_HTTPD; +import static com.suse.proxy.ProxyContainerImagesEnum.PROXY_SALT_BROKER; +import static com.suse.proxy.ProxyContainerImagesEnum.PROXY_SQUID; +import static com.suse.proxy.ProxyContainerImagesEnum.PROXY_SSH; +import static com.suse.proxy.ProxyContainerImagesEnum.PROXY_TFTPD; +import static com.suse.utils.Predicates.allAbsent; +import static com.suse.utils.Predicates.isAbsent; +import static com.suse.utils.Predicates.isProvided; + +import com.suse.proxy.model.ProxyConfig; +import com.suse.proxy.model.ProxyConfigImage; + +import com.redhat.rhn.domain.server.Pillar; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * Utility class for handling Proxy Config + * Includes relevant constants and mappings from DTO / Pillar + */ +public class ProxyConfigUtils { + + private static final String SAFE_SUFFIX = "_safe"; + private static final String CERTIFICATE_HEADER = "-----BEGIN CERTIFICATE-----\n"; + + + private ProxyConfigUtils() { + throw new UnsupportedOperationException(NOT_INSTANTIABLE); + } + + // + public static final String PROXY_PILLAR_CATEGORY = "proxy"; + + // Field names and values used in the form and also in the pillar + public static final String SERVER_ID_FIELD = "serverId"; + public static final String PROXY_FQDN_FIELD = "proxyFQDN"; + public static final String PARENT_FQDN_FIELD = "parentFQDN"; + public static final String PROXY_PORT_FIELD = "proxyPort"; + public static final String MAX_CACHE_FIELD = "maxSquidCacheSize"; + public static final String EMAIL_FIELD = "proxyAdminEmail"; + public static final String USE_CERTS_MODE_FIELD = "useCertsMode"; + public static final String USE_CERTS_MODE_KEEP = "keep"; + public static final String USE_CERTS_MODE_REPLACE = "replace"; + public static final String ROOT_CA_FIELD = "rootCA"; + public static final String INTERMEDIATE_CAS_FIELD = "intermediateCAs"; + public static final String PROXY_CERT_FIELD = "proxyCertificate"; + public static final String PROXY_KEY_FIELD = "proxyKey"; + public static final String SOURCE_MODE_FIELD = "sourceMode"; + public static final String SOURCE_MODE_RPM = "rpm"; + public static final String SOURCE_MODE_REGISTRY = "registry"; + public static final String REGISTRY_MODE = "registryMode"; + public static final String REGISTRY_MODE_SIMPLE = "simple"; + public static final String REGISTRY_MODE_ADVANCED = "advanced"; + public static final String REGISTRY_BASE_URL = "registryBaseURL"; + public static final String REGISTRY_BASE_TAG = "registryBaseTag"; + + + // Pillar entries + // The pillar entries for the registry URLs will follow the example format: + // { ..., "registries": { "proxy-httpd": { "url": "https://.../proxy-httpd", "tag": "latest" }, ... } } + // names for the registry entries are defined in ProxyContainerImagesEnum image names + public static final String PILLAR_REGISTRY_ENTRY = "registries"; + public static final String PILLAR_REGISTRY_URL_ENTRY = "url"; + public static final String PILLAR_REGISTRY_TAG_ENTRY = "tag"; + + + /** + * Maps a minion pillar ProxyConfig + * + * @param rootPillar the root pillar + * @return the ProxyConfig + */ + public static ProxyConfig proxyConfigFromPillar(Pillar rootPillar) { + Map pillar = rootPillar.getPillar(); + ProxyConfig proxyConfig = new ProxyConfig(); + + proxyConfig.setServerId((Long) pillar.get(SERVER_ID_FIELD)); + proxyConfig.setProxyFqdn(String.valueOf(pillar.get(PROXY_FQDN_FIELD))); + + proxyConfig.setParentFqdn(String.valueOf(pillar.get(PARENT_FQDN_FIELD))); + proxyConfig.setProxyPort((Integer) pillar.get(PROXY_PORT_FIELD)); + proxyConfig.setMaxCache((Integer) pillar.get(MAX_CACHE_FIELD)); + proxyConfig.setEmail(String.valueOf(pillar.get(EMAIL_FIELD))); + proxyConfig.setRootCA(String.valueOf(pillar.get(ROOT_CA_FIELD))); + proxyConfig.setIntermediateCAs((List) pillar.get(INTERMEDIATE_CAS_FIELD)); + proxyConfig.setProxyCert(String.valueOf(pillar.get(PROXY_CERT_FIELD))); + proxyConfig.setProxyKey(String.valueOf(pillar.get(PROXY_KEY_FIELD))); + + + Map registries = (Map) pillar.get(PILLAR_REGISTRY_ENTRY); + if (isProvided(registries)) { + Map httpdImageEntry = (Map) registries.get(PROXY_HTTPD.getImageName()); + Map saltBrokerImageEntry = (Map) registries.get(PROXY_SALT_BROKER.getImageName()); + Map squidImageEntry = (Map) registries.get(PROXY_SQUID.getImageName()); + Map sshImageEntry = (Map) registries.get(PROXY_SSH.getImageName()); + Map tftpfImageEntry = (Map) registries.get(PROXY_TFTPD.getImageName()); + + if (isProvided(httpdImageEntry)) { + proxyConfig.setHttpdImage( + new ProxyConfigImage( + httpdImageEntry.get(PILLAR_REGISTRY_URL_ENTRY), + httpdImageEntry.get(PILLAR_REGISTRY_TAG_ENTRY) + ) + ); + } + if (isProvided(saltBrokerImageEntry)) { + proxyConfig.setSaltBrokerImage( + new ProxyConfigImage( + saltBrokerImageEntry.get(PILLAR_REGISTRY_URL_ENTRY), + saltBrokerImageEntry.get(PILLAR_REGISTRY_TAG_ENTRY) + ) + ); + } + if (isProvided(squidImageEntry)) { + proxyConfig.setSquidImage( + new ProxyConfigImage( + squidImageEntry.get(PILLAR_REGISTRY_URL_ENTRY), + squidImageEntry.get(PILLAR_REGISTRY_TAG_ENTRY) + ) + ); + } + if (isProvided(sshImageEntry)) { + proxyConfig.setSshImage( + new ProxyConfigImage( + sshImageEntry.get(PILLAR_REGISTRY_URL_ENTRY), + sshImageEntry.get(PILLAR_REGISTRY_TAG_ENTRY) + ) + ); + } + if (isProvided(tftpfImageEntry)) { + proxyConfig.setTftpdImage( + new ProxyConfigImage( + tftpfImageEntry.get(PILLAR_REGISTRY_URL_ENTRY), + tftpfImageEntry.get(PILLAR_REGISTRY_TAG_ENTRY) + ) + ); + } + } + + return proxyConfig; + } + + /** + * Maps a ProxyConfig to a safe data map + * + * @param proxyConfig the ProxyConfig + * @return the safe data map + */ + public static Map safeDataMapFromProxyConfig(ProxyConfig proxyConfig) { + Map data = new HashMap<>(); + + if (isAbsent(proxyConfig)) { + return data; + } + + data.put(SERVER_ID_FIELD, proxyConfig.getServerId()); + data.put(PROXY_FQDN_FIELD, proxyConfig.getProxyFqdn()); + data.put(PARENT_FQDN_FIELD, proxyConfig.getParentFqdn()); + data.put(PROXY_PORT_FIELD, proxyConfig.getProxyPort()); + data.put(MAX_CACHE_FIELD, proxyConfig.getMaxCache()); + data.put(EMAIL_FIELD, proxyConfig.getEmail()); + data.put(ROOT_CA_FIELD + SAFE_SUFFIX, getSafeCertInput(proxyConfig.getRootCA())); + data.put(PROXY_CERT_FIELD + SAFE_SUFFIX, getSafeCertInput(proxyConfig.getProxyCert())); + data.put(PROXY_KEY_FIELD + SAFE_SUFFIX, getSafeCertInput(proxyConfig.getProxyKey())); + + List intermediateCAs = proxyConfig.getIntermediateCAs(); + if (isProvided(intermediateCAs)) { + data.put(INTERMEDIATE_CAS_FIELD + SAFE_SUFFIX, + intermediateCAs.stream() + .map(ProxyConfigUtils::getSafeCertInput) + .toList()); + } + + ProxyConfigImage httpdImage = proxyConfig.getHttpdImage(); + if (isProvided(httpdImage)) { + data.put(PROXY_HTTPD.getUrlField(), httpdImage.getUrl()); + data.put(PROXY_HTTPD.getTagField(), httpdImage.getTag()); + } + + ProxyConfigImage saltBrokerImage = proxyConfig.getSaltBrokerImage(); + if (isProvided(saltBrokerImage)) { + data.put(PROXY_SALT_BROKER.getUrlField(), saltBrokerImage.getUrl()); + data.put(PROXY_SALT_BROKER.getTagField(), saltBrokerImage.getTag()); + } + + ProxyConfigImage squidImage = proxyConfig.getSquidImage(); + if (isProvided(squidImage)) { + data.put(PROXY_SQUID.getUrlField(), squidImage.getUrl()); + data.put(PROXY_SQUID.getTagField(), squidImage.getTag()); + } + + ProxyConfigImage sshImage = proxyConfig.getSshImage(); + if (isProvided(sshImage)) { + data.put(PROXY_SSH.getUrlField(), sshImage.getUrl()); + data.put(PROXY_SSH.getTagField(), sshImage.getTag()); + } + + ProxyConfigImage tftpdImage = proxyConfig.getTftpdImage(); + if (isProvided(tftpdImage)) { + data.put(PROXY_TFTPD.getUrlField(), tftpdImage.getUrl()); + data.put(PROXY_TFTPD.getTagField(), tftpdImage.getTag()); + } + + if (allAbsent(httpdImage, saltBrokerImage, squidImage, sshImage, tftpdImage)) { + data.put(SOURCE_MODE_FIELD, ProxyConfigUtils.SOURCE_MODE_RPM); + } + else { + data.put(SOURCE_MODE_FIELD, ProxyConfigUtils.SOURCE_MODE_REGISTRY); + data.put(REGISTRY_MODE, ProxyConfigUtils.REGISTRY_MODE_ADVANCED); + } + + return data; + } + + + /** + * Maps a ProxyConfig pillar data to a ProxyConfig Map data meant for the apply_proxy_config salt state file + * + * @param rootPillar the root pillar containing the proxy data to be installed + * @return a map of the data for the apply_proxy_config salt state file + */ + public static Map applyProxyConfigDataFromPillar(Pillar rootPillar) { + Map pillar = rootPillar.getPillar(); + Map data = new HashMap<>(); + + data.put(PARENT_FQDN_FIELD, pillar.get(PARENT_FQDN_FIELD)); + data.put(PROXY_PORT_FIELD, pillar.get(PROXY_PORT_FIELD)); + data.put(MAX_CACHE_FIELD, pillar.get(MAX_CACHE_FIELD)); + data.put(EMAIL_FIELD, pillar.get(EMAIL_FIELD)); + data.put(ROOT_CA_FIELD, pillar.get(ROOT_CA_FIELD)); + data.put(INTERMEDIATE_CAS_FIELD, pillar.get(INTERMEDIATE_CAS_FIELD)); + data.put(PROXY_CERT_FIELD, pillar.get(PROXY_CERT_FIELD)); + data.put(PROXY_KEY_FIELD, pillar.get(PROXY_KEY_FIELD)); + + Map registries = (Map) pillar.get(PILLAR_REGISTRY_ENTRY); + if (isProvided(registries)) { + data.put(SOURCE_MODE_FIELD, ProxyConfigUtils.SOURCE_MODE_REGISTRY); + data.put(REGISTRY_MODE, ProxyConfigUtils.REGISTRY_MODE_ADVANCED); + for (ProxyContainerImagesEnum image : ProxyContainerImagesEnum.values()) { + Map registryEntry = (Map) registries.get(image.getImageName()); + if (isProvided(registryEntry)) { + data.put(image.getPillarImageVariableName(), registryEntry.get(PILLAR_REGISTRY_URL_ENTRY)); + data.put(image.getPillarTagVariableName(), registryEntry.get(PILLAR_REGISTRY_TAG_ENTRY)); + } + } + } + else { + data.put(SOURCE_MODE_FIELD, ProxyConfigUtils.SOURCE_MODE_RPM); + } + + return data; + } + + + /** + * Returns a preview of the certificate to be used as a (safe) preview + * Truncates the input to the 10 characters. + * + * @param cert the input string + * @return the safe certificate input + */ + public static String getSafeCertInput(String cert) { + if (isAbsent(cert)) { + return null; + } + String content = cert.startsWith(CERTIFICATE_HEADER) ? cert.substring(CERTIFICATE_HEADER.length()) : cert; + return content.length() <= 10 ? content : content.substring(0, 10) + "..."; + } +} diff --git a/java/code/src/com/suse/proxy/ProxyContainerImagesEnum.java b/java/code/src/com/suse/proxy/ProxyContainerImagesEnum.java new file mode 100644 index 000000000000..26e99ae6d7f8 --- /dev/null +++ b/java/code/src/com/suse/proxy/ProxyContainerImagesEnum.java @@ -0,0 +1,70 @@ +/* + * Copyright (c) 2025 SUSE LLC + * + * This software is licensed to you under the GNU General Public License, + * version 2 (GPLv2). There is NO WARRANTY for this software, express or + * implied, including the implied warranties of MERCHANTABILITY or FITNESS + * FOR A PARTICULAR PURPOSE. You should have received a copy of GPLv2 + * along with this software; if not, see + * http://www.gnu.org/licenses/old-licenses/gpl-2.0.txt. + * + * Red Hat trademarks are not licensed under GPLv2. No permission is + * granted to use or replicate Red Hat trademarks that are incorporated + * in this software or its documentation. + */ + +package com.suse.proxy; + +/** + * Enum for the proxy container images + * Holds helpful names used to identify the images in the registry + */ +public enum ProxyContainerImagesEnum { + PROXY_HTTPD("proxy-httpd", "registryHttpdURL", "registryHttpdTag", "httpd_image", "httpd_tag"), + PROXY_SALT_BROKER("proxy-salt-broker", "registrySaltbrokerURL", "registrySaltbrokerTag" , "saltbroker_image", "saltbroker_tag"), + PROXY_SQUID("proxy-squid", "registrySquidURL", "registrySquidTag", "squid_image", "squid_tag"), + PROXY_SSH("proxy-ssh", "registrySshURL", "registrySshTag", "ssh_image", "ssh_tag"), + PROXY_TFTPD("proxy-tftpd", "registryTftpdURL", "registryTftpdTag", "tftpd_image", "tftpd_tag"); + + private final String imageName; + private final String urlField; + private final String tagField; + private final String pillarImageVariableName; + private final String pillarTagVariableName; + + /** + * Constructor + * @param imageNameIn The name of the image, used to identify the image in the registry and also the pillar entry + * @param urlFieldIn The field name in the form that holds the URL of the image + * @param tagFieldIn The field name in the form that holds the tag of the image + * @param pillarImageVariableNameIn The name of the pillar entry that holds the image name (mainly for the sls file) + * @param pillarTagVariableNameIn The name of the pillar entry that holds the image tag (mainly for the sls file) + */ + ProxyContainerImagesEnum(String imageNameIn, String urlFieldIn, String tagFieldIn, String pillarImageVariableNameIn, String pillarTagVariableNameIn) { + imageName = imageNameIn; + urlField = urlFieldIn; + tagField = tagFieldIn; + pillarImageVariableName = pillarImageVariableNameIn; + pillarTagVariableName = pillarTagVariableNameIn; + } + + public String getImageName() { + return imageName; + } + + public String getUrlField() { + return urlField; + } + + public String getTagField() { + return tagField; + } + + public String getPillarImageVariableName() { + return pillarImageVariableName; + } + + public String getPillarTagVariableName() { + return pillarTagVariableName; + } +} diff --git a/java/code/src/com/suse/proxy/ProxyRegistryUtils.java b/java/code/src/com/suse/proxy/ProxyRegistryUtils.java new file mode 100644 index 000000000000..01c9e3c19c08 --- /dev/null +++ b/java/code/src/com/suse/proxy/ProxyRegistryUtils.java @@ -0,0 +1,200 @@ +/* + * Copyright (c) 2025 SUSE LLC + * + * This software is licensed to you under the GNU General Public License, + * version 2 (GPLv2). There is NO WARRANTY for this software, express or + * implied, including the implied warranties of MERCHANTABILITY or FITNESS + * FOR A PARTICULAR PURPOSE. You should have received a copy of GPLv2 + * along with this software; if not, see + * http://www.gnu.org/licenses/old-licenses/gpl-2.0.txt. + * + * Red Hat trademarks are not licensed under GPLv2. No permission is + * granted to use or replicate Red Hat trademarks that are incorporated + * in this software or its documentation. + */ + +package com.suse.proxy; + +import static com.redhat.rhn.common.ExceptionMessage.NOT_INSTANTIABLE; +import static com.suse.utils.Predicates.allProvided; +import static com.suse.utils.Predicates.isProvided; +import static java.nio.charset.StandardCharsets.UTF_8; + +import com.redhat.rhn.common.RhnRuntimeException; +import com.redhat.rhn.domain.credentials.CredentialsFactory; +import com.redhat.rhn.domain.credentials.SCCCredentials; + +import com.suse.manager.api.ParseException; +import com.suse.rest.RestClient; +import com.suse.rest.RestRequestBuilder; +import com.suse.rest.RestRequestMethodEnum; +import com.suse.rest.RestResponse; + +import com.google.gson.JsonObject; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +import java.net.URLEncoder; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.function.Function; + +public class ProxyRegistryUtils { + + private static final Logger LOG = LogManager.getLogger(ProxyRegistryUtils.class); + private static final String SCC_AUTH_API_URL = "https://scc.suse.com/api/registry/authorize"; + + + private ProxyRegistryUtils() { + throw new UnsupportedOperationException(NOT_INSTANTIABLE); + } + + /** + * Executes a registry request with a retry mechanism. + * If the first request fails with a 401 Unauthorized, a bearer token is retrieved and the request is retried. + * + * @param restRequestBuilder the request builder + * @param responseHandler the response handler + * @param the type of the response + * @return the response + * @throws ParseException if the response cannot be parsed + */ + public static T executeWithRetry( + RestRequestBuilder restRequestBuilder, + Function responseHandler + ) throws ParseException { + RestResponse response = RestClient.getInstance().execute(restRequestBuilder.build()); + T result = responseHandler.apply(response); + + if (response.isSuccessful()) { + return result; + } + + // If the registry requires authorization it will return a 401 Unauthorized HTTP + // In such case, retrieve a bearer token from the SCC and retry + if (response.getStatusCode() == 401) { + String bearerToken = getBearerToken(response); + if (isProvided(bearerToken)) { + restRequestBuilder.bearerToken(bearerToken); + response = RestClient.getInstance().execute(restRequestBuilder.build()); + result = responseHandler.apply(response); + + if (response.getStatusCode() == 200) { + return result; + } + } + } + + // If the retry also fails, log the issue and return the provided default value + LOG.debug("Request failed after retrying with bearer token. Status Code: {}, Response: {}", + response.getStatusCode(), response.getBody()); + throw new RhnRuntimeException("Failed to execute request: " + response.getStatusCode()); + } + + /** + * Retrieves the list of repositories from the registry. + * + * @param registryUrl the registry URL + * @return the list of repositories + * @throws ParseException if the response cannot be parsed + */ + public static List getRepositories(RegistryUrl registryUrl) throws ParseException { + return executeWithRetry( + new RestRequestBuilder(RestRequestMethodEnum.GET, registryUrl.getCatalogUrl()), + response -> { + try { + return (List) response.getBodyAs(Map.class).get("repositories"); + } + catch (ParseException e) { + throw new RhnRuntimeException(e); + } + } + ); + } + + /** + * Retrieves the list of tags from the registry. + * + * @param registryUrl the registry URL + * @return the list of tags + * @throws ParseException if the response cannot be parsed + */ + public static List getTags(RegistryUrl registryUrl) throws ParseException { + return executeWithRetry( + new RestRequestBuilder(RestRequestMethodEnum.GET, registryUrl.getTagListUrl()), + response -> { + try { + return (List) response.getBodyAs(Map.class).get("tags"); + } + catch (ParseException e) { + throw new RhnRuntimeException(e); + } + } + ); + } + + /** + * Retrieves the bearer token from the response. + * + * @param response the response + * @return the bearer token or null if failed to match all requirements + * @throws ParseException if the response cannot be parsed + */ + public static String getBearerToken(RestResponse response) throws ParseException { + List wwwAuthenticateList = null; + for (String key : response.getHeaders().keySet()) { + if (key != null && key.equalsIgnoreCase("WWW-Authenticate")) { + wwwAuthenticateList = response.getHeaders().get(key); + break; + } + } + if (wwwAuthenticateList == null || wwwAuthenticateList.isEmpty()) { + LOG.debug("No 'WWW-Authenticate' header (case insensitive) found in the response"); + return null; + } + + String wwwAuthenticate = wwwAuthenticateList.get(0); + Map authParams = new HashMap<>(); + for (String item : wwwAuthenticate.split(",")) { + if (item.contains("=")) { + String[] parts = item.split("=", 2); + String key = parts[0].trim(); + String value = parts[1].replace("\"", "").trim(); + authParams.put(key, value); + } + } + + String bearerRealm = authParams.get("Bearer realm"); + String service = authParams.get("service"); + String scope = authParams.get("scope"); + + if (!allProvided(bearerRealm, service, scope)) { + LOG.debug("Not all required parameters found in 'Www-Authenticate' header: {}", authParams); + return null; + } + + // If the bearerRealm is NOT the SCC_AUTH_API_URL, we don't want to provide scc credentials + if (!SCC_AUTH_API_URL.equals(bearerRealm)) { + LOG.debug("Bearer realm does not match {}, it is {}", SCC_AUTH_API_URL, bearerRealm); + return null; + } + + String authUrl = bearerRealm + "?service=" + URLEncoder.encode(service, UTF_8) + "&scope=" + scope; + + SCCCredentials sccCredentials = CredentialsFactory.listSCCCredentials().get(0); + RestRequestBuilder sccTokenRequest = new RestRequestBuilder(RestRequestMethodEnum.GET, authUrl); + sccTokenRequest.basicAuth(sccCredentials.getUsername(), sccCredentials.getPassword()); + RestResponse sccTokenResponse = RestClient.getInstance().execute(sccTokenRequest.build()); + + if (!sccTokenResponse.isSuccessful()) { + LOG.debug("Failed to retrieve bearer token from SCC: {}", sccTokenResponse); + return null; + } + + JsonObject jsonResponse = sccTokenResponse.getBodyAs(JsonObject.class); + return jsonResponse.get("token").getAsString(); + } + +} diff --git a/java/code/src/com/suse/proxy/RegistryUrl.java b/java/code/src/com/suse/proxy/RegistryUrl.java new file mode 100644 index 000000000000..ea4498940ada --- /dev/null +++ b/java/code/src/com/suse/proxy/RegistryUrl.java @@ -0,0 +1,120 @@ +/* + * Copyright (c) 2025 SUSE LLC + * + * This software is licensed to you under the GNU General Public License, + * version 2 (GPLv2). There is NO WARRANTY for this software, express or + * implied, including the implied warranties of MERCHANTABILITY or FITNESS + * FOR A PARTICULAR PURPOSE. You should have received a copy of GPLv2 + * along with this software; if not, see + * http://www.gnu.org/licenses/old-licenses/gpl-2.0.txt. + * + * Red Hat trademarks are not licensed under GPLv2. No permission is + * granted to use or replicate Red Hat trademarks that are incorporated + * in this software or its documentation. + */ + +package com.suse.proxy; + +import static com.suse.utils.Predicates.isProvided; + +import com.redhat.rhn.common.RhnRuntimeException; + +import java.net.URI; +import java.net.URISyntaxException; + +public class RegistryUrl { + + private final URI url; + private String tag; + + /** + * Constructor to create a RegistryUrlHandler instance. + * + * @param urlIn the input registry URL + * @throws URISyntaxException if the URL is invalid + */ + public RegistryUrl(String urlIn) throws URISyntaxException, RhnRuntimeException { + if (!isProvided(urlIn)) { + throw new RhnRuntimeException("url not provided"); + } + + this.url = new URI(normalizeRegistryUrl(urlIn)); + + int colonIndex = url.getPath().lastIndexOf(":"); + if (colonIndex > 0) { + this.tag = url.getPath().substring(colonIndex + 1); + } + } + + /** + * Constructor to create a RegistryUrlHandler instance. + * + * @param urlIn the input registry URL + * @param tagIn the tag + * @throws URISyntaxException if the URL is invalid + * @throws RhnRuntimeException if the URL is not provided + */ + public RegistryUrl(String urlIn, String tagIn) throws URISyntaxException, RhnRuntimeException { + if (!isProvided(urlIn)) { + throw new RhnRuntimeException("url not provided"); + } + + this.url = new URI(normalizeRegistryUrl(urlIn)); + this.tag = tagIn; + } + + /** + * Normalizes the input registry URL by: + * - Trimming whitespace + * - Adding "https://" if no protocol is set + * - Removing trailing "/" + * + * @param registryUrlIn the input URL + * @return the normalized URL as a string + */ + private String normalizeRegistryUrl(String registryUrlIn) { + registryUrlIn = registryUrlIn.trim(); + + if (!registryUrlIn.matches("^[a-zA-Z][a-zA-Z0-9+.-]*://.*")) { + registryUrlIn = "https://" + registryUrlIn; + } + + if (registryUrlIn.endsWith("/")) { + registryUrlIn = registryUrlIn.substring(0, registryUrlIn.length() - 1); + } + + return registryUrlIn; + } + + public String getDomain() { + return url.getHost(); + } + + public String getPath() { + return url.getPath(); + } + + public String getRegistry() { + return url.getHost() + url.getPath(); + } + + public String getCatalogUrl() { + return url.getScheme() + "://" + url.getHost() + "/v2/_catalog"; + } + + public String getTagListUrl() { + return url.getScheme() + "://" + url.getHost() + "/v2" + url.getPath() + "/tags/list"; + } + + public String getUrl() { + return url.toString(); + } + + public String getTag() { + return tag; + } + + public void setTag(String tagIn) { + tag = tagIn; + } +} diff --git a/java/code/src/com/suse/proxy/get/ProxyConfigGet.java b/java/code/src/com/suse/proxy/get/ProxyConfigGet.java new file mode 100644 index 000000000000..3738903ec274 --- /dev/null +++ b/java/code/src/com/suse/proxy/get/ProxyConfigGet.java @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2025 SUSE LLC + * + * This software is licensed to you under the GNU General Public License, + * version 2 (GPLv2). There is NO WARRANTY for this software, express or + * implied, including the implied warranties of MERCHANTABILITY or FITNESS + * FOR A PARTICULAR PURPOSE. You should have received a copy of GPLv2 + * along with this software; if not, see + * http://www.gnu.org/licenses/old-licenses/gpl-2.0.txt. + * + * Red Hat trademarks are not licensed under GPLv2. No permission is + * granted to use or replicate Red Hat trademarks that are incorporated + * in this software or its documentation. + */ + +package com.suse.proxy.get; + +import static com.suse.proxy.ProxyConfigUtils.PROXY_PILLAR_CATEGORY; +import static com.suse.utils.Predicates.isAbsent; + +import com.redhat.rhn.domain.server.Server; + +import com.suse.proxy.ProxyConfigUtils; +import com.suse.proxy.model.ProxyConfig; + +public class ProxyConfigGet { + + /** + * Get the proxy configuration + * @param server the server + * @return the proxy configuration + */ + public ProxyConfig get(Server server) { + if (isAbsent(server)) { + return null; + } + return server.asMinionServer() + .flatMap(minionServer -> minionServer + .getPillarByCategory(PROXY_PILLAR_CATEGORY) + .map(ProxyConfigUtils::proxyConfigFromPillar)) + .orElse(null); + } +} diff --git a/java/code/src/com/suse/proxy/model/ProxyConfig.java b/java/code/src/com/suse/proxy/model/ProxyConfig.java new file mode 100644 index 000000000000..a1c300b1290d --- /dev/null +++ b/java/code/src/com/suse/proxy/model/ProxyConfig.java @@ -0,0 +1,159 @@ +/* + * Copyright (c) 2025 SUSE LLC + * + * This software is licensed to you under the GNU General Public License, + * version 2 (GPLv2). There is NO WARRANTY for this software, express or + * implied, including the implied warranties of MERCHANTABILITY or FITNESS + * FOR A PARTICULAR PURPOSE. You should have received a copy of GPLv2 + * along with this software; if not, see + * http://www.gnu.org/licenses/old-licenses/gpl-2.0.txt. + * + * Red Hat trademarks are not licensed under GPLv2. No permission is + * granted to use or replicate Red Hat trademarks that are incorporated + * in this software or its documentation. + */ + +package com.suse.proxy.model; + +import java.util.List; + +public class ProxyConfig { + + private Long serverId; + private String proxyFqdn; + private String parentFqdn; + private Integer proxyPort; + private Integer maxCache; + private String email; + + private String rootCA; + private List intermediateCAs; + private String proxyCert; + private String proxyKey; + + private ProxyConfigImage httpdImage; + private ProxyConfigImage saltBrokerImage; + private ProxyConfigImage squidImage; + private ProxyConfigImage sshImage; + private ProxyConfigImage tftpdImage; + + public Long getServerId() { + return serverId; + } + + public void setServerId(Long serverIdIn) { + serverId = serverIdIn; + } + + public Integer getProxyPort() { + return proxyPort; + } + + public void setProxyPort(Integer proxyPortIn) { + proxyPort = proxyPortIn; + } + + public String getProxyFqdn() { + return proxyFqdn; + } + + public void setProxyFqdn(String proxyFqdnIn) { + proxyFqdn = proxyFqdnIn; + } + + public String getParentFqdn() { + return parentFqdn; + } + + public void setParentFqdn(String parentFqdnIn) { + parentFqdn = parentFqdnIn; + } + + public Integer getMaxCache() { + return maxCache; + } + + public void setMaxCache(Integer maxCacheIn) { + maxCache = maxCacheIn; + } + + public String getEmail() { + return email; + } + + public void setEmail(String emailIn) { + email = emailIn; + } + + public String getRootCA() { + return rootCA; + } + + public void setRootCA(String rootCAIn) { + rootCA = rootCAIn; + } + + public List getIntermediateCAs() { + return intermediateCAs; + } + + public void setIntermediateCAs(List intermediateCAsIn) { + intermediateCAs = intermediateCAsIn; + } + + public String getProxyCert() { + return proxyCert; + } + + public void setProxyCert(String proxyCertIn) { + proxyCert = proxyCertIn; + } + + public String getProxyKey() { + return proxyKey; + } + + public void setProxyKey(String proxyKeyIn) { + proxyKey = proxyKeyIn; + } + + public ProxyConfigImage getHttpdImage() { + return httpdImage; + } + + public void setHttpdImage(ProxyConfigImage httpdImageIn) { + httpdImage = httpdImageIn; + } + + public ProxyConfigImage getSaltBrokerImage() { + return saltBrokerImage; + } + + public void setSaltBrokerImage(ProxyConfigImage saltBrokerImageIn) { + saltBrokerImage = saltBrokerImageIn; + } + + public ProxyConfigImage getSquidImage() { + return squidImage; + } + + public void setSquidImage(ProxyConfigImage squidImageIn) { + squidImage = squidImageIn; + } + + public ProxyConfigImage getSshImage() { + return sshImage; + } + + public void setSshImage(ProxyConfigImage sshImageIn) { + sshImage = sshImageIn; + } + + public ProxyConfigImage getTftpdImage() { + return tftpdImage; + } + + public void setTftpdImage(ProxyConfigImage tftpdImageIn) { + tftpdImage = tftpdImageIn; + } +} diff --git a/java/code/src/com/suse/proxy/model/ProxyConfigImage.java b/java/code/src/com/suse/proxy/model/ProxyConfigImage.java new file mode 100644 index 000000000000..d07426b4b1c3 --- /dev/null +++ b/java/code/src/com/suse/proxy/model/ProxyConfigImage.java @@ -0,0 +1,54 @@ +/* + * Copyright (c) 2025 SUSE LLC + * + * This software is licensed to you under the GNU General Public License, + * version 2 (GPLv2). There is NO WARRANTY for this software, express or + * implied, including the implied warranties of MERCHANTABILITY or FITNESS + * FOR A PARTICULAR PURPOSE. You should have received a copy of GPLv2 + * along with this software; if not, see + * http://www.gnu.org/licenses/old-licenses/gpl-2.0.txt. + * + * Red Hat trademarks are not licensed under GPLv2. No permission is + * granted to use or replicate Red Hat trademarks that are incorporated + * in this software or its documentation. + */ + +package com.suse.proxy.model; + +public class ProxyConfigImage { + + private String url; + private String tag; + + /** + * Default constructor + */ + public ProxyConfigImage() { + } + + /** + * Constructor + * @param urlIn the URL + * @param tagIn the tag + */ + public ProxyConfigImage(String urlIn, String tagIn) { + url = urlIn; + tag = tagIn; + } + + public String getUrl() { + return url; + } + + public void setUrl(String urlIn) { + url = urlIn; + } + + public String getTag() { + return tag; + } + + public void setTag(String tagIn) { + tag = tagIn; + } +} diff --git a/java/code/src/com/suse/proxy/update/ProxyConfigUpdate.java b/java/code/src/com/suse/proxy/update/ProxyConfigUpdate.java new file mode 100644 index 000000000000..af2fb193e0cc --- /dev/null +++ b/java/code/src/com/suse/proxy/update/ProxyConfigUpdate.java @@ -0,0 +1,62 @@ +/* + * Copyright (c) 2025 SUSE LLC + * + * This software is licensed to you under the GNU General Public License, + * version 2 (GPLv2). There is NO WARRANTY for this software, express or + * implied, including the implied warranties of MERCHANTABILITY or FITNESS + * FOR A PARTICULAR PURPOSE. You should have received a copy of GPLv2 + * along with this software; if not, see + * http://www.gnu.org/licenses/old-licenses/gpl-2.0.txt. + * + * Red Hat trademarks are not licensed under GPLv2. No permission is + * granted to use or replicate Red Hat trademarks that are incorporated + * in this software or its documentation. + */ + +package com.suse.proxy.update; + +import static java.util.Arrays.asList; + +import com.redhat.rhn.domain.user.User; +import com.redhat.rhn.manager.system.SystemManager; + +import com.suse.manager.webui.services.iface.SaltApi; +import com.suse.manager.webui.utils.gson.ProxyConfigUpdateJson; + +import java.util.ArrayList; +import java.util.List; + +public class ProxyConfigUpdate { + private final List contextHandlerChain = new ArrayList<>(); + + /** + * Constructor + */ + public ProxyConfigUpdate() { + this.contextHandlerChain.addAll(asList( + new ProxyConfigUpdateAcquisitor(), + new ProxyConfigUpdateValidation(), + new ProxyConfigUpdateRegistryPreConditions(), + new ProxyConfigUpdateFileAcquisitor(), + new ProxyConfigUpdateSavePillars(), + new ProxyConfigUpdateApplySaltState() + )); + } + + /** + * Update the proxy configuration + * + * @param request the proxy configuration update JSON with the new values + * @param systemManager the system manager + * @param saltApi the salt API + * @param user the user + */ + public void update(ProxyConfigUpdateJson request, SystemManager systemManager, SaltApi saltApi, User user) { + ProxyConfigUpdateContext context = new ProxyConfigUpdateContext(request, systemManager, saltApi, user); + + for (ProxyConfigUpdateContextHandler handler : contextHandlerChain) { + handler.handle(context); + context.getErrorReport().report(); + } + } +} diff --git a/java/code/src/com/suse/proxy/update/ProxyConfigUpdateAcquisitor.java b/java/code/src/com/suse/proxy/update/ProxyConfigUpdateAcquisitor.java new file mode 100644 index 000000000000..80e98a56a0cf --- /dev/null +++ b/java/code/src/com/suse/proxy/update/ProxyConfigUpdateAcquisitor.java @@ -0,0 +1,166 @@ +/* + * Copyright (c) 2025 SUSE LLC + * + * This software is licensed to you under the GNU General Public License, + * version 2 (GPLv2). There is NO WARRANTY for this software, express or + * implied, including the implied warranties of MERCHANTABILITY or FITNESS + * FOR A PARTICULAR PURPOSE. You should have received a copy of GPLv2 + * along with this software; if not, see + * http://www.gnu.org/licenses/old-licenses/gpl-2.0.txt. + * + * Red Hat trademarks are not licensed under GPLv2. No permission is + * granted to use or replicate Red Hat trademarks that are incorporated + * in this software or its documentation. + */ + +package com.suse.proxy.update; + +import static com.suse.proxy.ProxyConfigUtils.REGISTRY_MODE_ADVANCED; +import static com.suse.proxy.ProxyConfigUtils.REGISTRY_MODE_SIMPLE; +import static com.suse.proxy.ProxyConfigUtils.SOURCE_MODE_REGISTRY; +import static com.suse.proxy.ProxyConfigUtils.USE_CERTS_MODE_KEEP; +import static com.suse.utils.Predicates.isAbsent; +import static com.suse.utils.Predicates.isProvided; +import static java.util.Optional.ofNullable; + +import com.redhat.rhn.domain.server.MinionServerFactory; +import com.redhat.rhn.domain.server.ServerFQDN; +import com.redhat.rhn.domain.server.ServerFactory; + +import com.suse.manager.webui.utils.gson.ProxyConfigUpdateJson; +import com.suse.proxy.ProxyContainerImagesEnum; +import com.suse.proxy.RegistryUrl; +import com.suse.proxy.get.ProxyConfigGet; +import com.suse.proxy.model.ProxyConfig; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +import java.net.URISyntaxException; + +/** + * Acquires additional information from the request data + */ +public class ProxyConfigUpdateAcquisitor implements ProxyConfigUpdateContextHandler { + private static final Logger LOG = LogManager.getLogger(ProxyConfigUpdateAcquisitor.class); + + @Override + public void handle(ProxyConfigUpdateContext context) { + acquireProxyMinion(context); + acquireCertificates(context); + acquireParentServer(context); + buildRegistryUrls(context); + } + + /** + * Acquires the proxy minion and its configuration if already exists + * + * @param context the context + */ + private void acquireProxyMinion(ProxyConfigUpdateContext context) { + Long serverId = context.getRequest().getServerId(); + if (isProvided(serverId)) { + MinionServerFactory.lookupById(serverId).ifPresent(minionServer -> { + if (minionServer.hasProxyEntitlement()) { + context.setProxyMinion(minionServer); + context.setProxyFqdn(ofNullable(minionServer.findPrimaryFqdn()) + .map(ServerFQDN::getName) + .orElse(minionServer.getName())); + context.setProxyConfig(new ProxyConfigGet().get(minionServer)); + } + }); + } + } + + /** + * Acquires the certificates from the request or the current proxy configuration + * + * @param context the context + */ + private void acquireCertificates(ProxyConfigUpdateContext context) { + ProxyConfigUpdateJson request = context.getRequest(); + if (isAbsent(request.getUseCertsMode())) { + return; + } + boolean keepCerts = USE_CERTS_MODE_KEEP.equals(request.getUseCertsMode()); + ProxyConfig proxyConfig = context.getProxyConfig(); + if (keepCerts && isAbsent(proxyConfig)) { + return; + } + + context.setRootCA(keepCerts ? proxyConfig.getRootCA() : request.getRootCA()); + context.setIntermediateCAs(keepCerts ? proxyConfig.getIntermediateCAs() : request.getIntermediateCAs()); + context.setProxyCert(keepCerts ? proxyConfig.getProxyCert() : request.getProxyCert()); + context.setProxyKey(keepCerts ? proxyConfig.getProxyKey() : request.getProxyKey()); + } + + /** + * Acquires the parent server if provided + * + * @param context the context + */ + private void acquireParentServer(ProxyConfigUpdateContext context) { + String parentFqdn = context.getRequest().getParentFqdn(); + if (isProvided(parentFqdn)) { + ServerFactory.findByFqdn(parentFqdn).ifPresent(server -> { + if (server.isMgrServer() || server.isProxy()) { + context.setParentServer(server); + } + }); + } + } + + /** + * Builds the registry URLs for the proxy container images + * + * @param context the context + */ + private void buildRegistryUrls(ProxyConfigUpdateContext context) { + ProxyConfigUpdateJson request = context.getRequest(); + if (!SOURCE_MODE_REGISTRY.equals(request.getSourceMode())) { + return; + } + + try { + if (REGISTRY_MODE_SIMPLE.equals(request.getRegistryMode())) { + String registryBaseURL = request.getRegistryBaseURL(); + String registryBaseTag = request.getRegistryBaseTag(); + String separator = registryBaseURL.endsWith("/") ? "" : "/"; + + for (ProxyContainerImagesEnum proxyImage : ProxyContainerImagesEnum.values()) { + context.getRegistryUrls().put( + proxyImage, + new RegistryUrl(registryBaseURL + separator + proxyImage.getImageName(), registryBaseTag) + ); + } + } + else if (REGISTRY_MODE_ADVANCED.equals(request.getRegistryMode())) { + context.getRegistryUrls().put( + ProxyContainerImagesEnum.PROXY_HTTPD, + new RegistryUrl(request.getRegistryHttpdURL(), request.getRegistryHttpdTag()) + ); + context.getRegistryUrls().put( + ProxyContainerImagesEnum.PROXY_SALT_BROKER, + new RegistryUrl(request.getRegistrySaltbrokerURL(), request.getRegistrySaltbrokerTag()) + ); + context.getRegistryUrls().put( + ProxyContainerImagesEnum.PROXY_SQUID, + new RegistryUrl(request.getRegistrySquidURL(), request.getRegistrySquidTag()) + ); + context.getRegistryUrls().put( + ProxyContainerImagesEnum.PROXY_SSH, + new RegistryUrl(request.getRegistrySshURL(), request.getRegistrySshTag()) + ); + context.getRegistryUrls().put( + ProxyContainerImagesEnum.PROXY_TFTPD, + new RegistryUrl(request.getRegistryTftpdURL(), request.getRegistryTftpdTag()) + ); + } + } + catch (URISyntaxException e) { + LOG.debug("Invalid creating Registry URL {}", context); + context.getErrorReport().register("Invalid Registry URL"); + } + } + +} diff --git a/java/code/src/com/suse/proxy/update/ProxyConfigUpdateApplySaltState.java b/java/code/src/com/suse/proxy/update/ProxyConfigUpdateApplySaltState.java new file mode 100644 index 000000000000..891a9fe5d7f3 --- /dev/null +++ b/java/code/src/com/suse/proxy/update/ProxyConfigUpdateApplySaltState.java @@ -0,0 +1,75 @@ +/* + * Copyright (c) 2025 SUSE LLC + * + * This software is licensed to you under the GNU General Public License, + * version 2 (GPLv2). There is NO WARRANTY for this software, express or + * implied, including the implied warranties of MERCHANTABILITY or FITNESS + * FOR A PARTICULAR PURPOSE. You should have received a copy of GPLv2 + * along with this software; if not, see + * http://www.gnu.org/licenses/old-licenses/gpl-2.0.txt. + * + * Red Hat trademarks are not licensed under GPLv2. No permission is + * granted to use or replicate Red Hat trademarks that are incorporated + * in this software or its documentation. + */ + +package com.suse.proxy.update; + +import static com.suse.utils.Predicates.isAbsent; + +import com.redhat.rhn.GlobalInstanceHolder; +import com.redhat.rhn.domain.action.ActionFactory; +import com.redhat.rhn.domain.action.ProxyConfigurationApplyAction; +import com.redhat.rhn.manager.action.ActionManager; + +import com.suse.salt.netapi.calls.LocalCall; + +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +import java.util.Map; +import java.util.Optional; + +/** + * Applies proxy configuration salt state + */ +public class ProxyConfigUpdateApplySaltState implements ProxyConfigUpdateContextHandler { + private static final Logger LOG = LogManager.getLogger(ProxyConfigUpdateApplySaltState.class); + + @Override + public void handle(ProxyConfigUpdateContext context) { + ProxyConfigurationApplyAction action = new ProxyConfigurationApplyAction(context.getPillar(), context.getProxyConfigFiles()); + action.setActionType(ActionFactory.TYPE_PROXY_CONFIGURATION_APPLY); + action.setOrg(context.getUser().getOrg()); + action.setName("Apply proxy configuration: " + context.getProxyMinion().getMinionId()); + ActionManager.addServerToAction(context.getProxyMinion(), action); + Map, Optional> applySaltStateResponse = + GlobalInstanceHolder.SALT_SERVER_ACTION_SERVICE.executeSSHAction(action, context.getProxyMinion()); + + if (isAbsent(applySaltStateResponse) || applySaltStateResponse.size() != 1) { + context.getErrorReport().register("Failed to apply proxy configuration salt state."); + LOG.debug("Failed to apply proxy configuration salt state."); + return; + } + + Optional singleEntry = applySaltStateResponse.values().iterator().next(); + singleEntry.ifPresentOrElse( + jsonElement -> { + JsonObject jsonObject = jsonElement.getAsJsonObject(); + for (String key : jsonObject.keySet()) { + if (!jsonObject.get(key).getAsJsonObject().get("result").getAsBoolean()) { + context.getErrorReport().register("Failed to apply proxy configuration salt state."); + LOG.debug("Failed to apply proxy configuration salt state. %s at key %s", singleEntry, key); + } + } + }, + () -> { + context.getErrorReport().register("Failed to apply proxy configuration salt state. Unexpected response."); + LOG.debug("Failed to apply proxy configuration salt state. Unexpected response. %s", singleEntry); + } + ); + } +} \ No newline at end of file diff --git a/java/code/src/com/suse/proxy/update/ProxyConfigUpdateContext.java b/java/code/src/com/suse/proxy/update/ProxyConfigUpdateContext.java new file mode 100644 index 000000000000..0183d5e6d74d --- /dev/null +++ b/java/code/src/com/suse/proxy/update/ProxyConfigUpdateContext.java @@ -0,0 +1,177 @@ +/* + * Copyright (c) 2025 SUSE LLC + * + * This software is licensed to you under the GNU General Public License, + * version 2 (GPLv2). There is NO WARRANTY for this software, express or + * implied, including the implied warranties of MERCHANTABILITY or FITNESS + * FOR A PARTICULAR PURPOSE. You should have received a copy of GPLv2 + * along with this software; if not, see + * http://www.gnu.org/licenses/old-licenses/gpl-2.0.txt. + * + * Red Hat trademarks are not licensed under GPLv2. No permission is + * granted to use or replicate Red Hat trademarks that are incorporated + * in this software or its documentation. + */ + +package com.suse.proxy.update; + +import com.redhat.rhn.common.RhnErrorReport; +import com.redhat.rhn.domain.server.MinionServer; +import com.redhat.rhn.domain.server.Pillar; +import com.redhat.rhn.domain.server.Server; +import com.redhat.rhn.domain.user.User; +import com.redhat.rhn.manager.system.SystemManager; + +import com.suse.manager.webui.services.iface.SaltApi; +import com.suse.manager.webui.utils.gson.ProxyConfigUpdateJson; +import com.suse.proxy.ProxyContainerImagesEnum; +import com.suse.proxy.RegistryUrl; +import com.suse.proxy.model.ProxyConfig; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +public class ProxyConfigUpdateContext { + + private final ProxyConfigUpdateJson request; + private final RhnErrorReport errorReport = new RhnErrorReport(); + private final Map registryUrls = new HashMap<>(ProxyContainerImagesEnum.values().length); + private final SystemManager systemManager; + private final User user; + private final SaltApi saltApi; + + private String proxyFqdn; + private String rootCA; + private List intermediateCAs; + private String proxyCert; + private String proxyKey; + + + private MinionServer proxyMinion; + private Server parentServer; + private ProxyConfig proxyConfig; + private Map proxyConfigFiles; + + private Pillar pillar; + + /** + * Constructor + * + * @param requestIn the request + * @param systemManagerIn the system manager + * @param saltApiIn the salt API + * @param userIn the user + */ + public ProxyConfigUpdateContext(ProxyConfigUpdateJson requestIn, SystemManager systemManagerIn, SaltApi saltApiIn, User userIn) { + this.request = requestIn; + this.systemManager = systemManagerIn; + this.saltApi = saltApiIn; + this.user = userIn; + } + + public ProxyConfigUpdateJson getRequest() { + return request; + } + + public RhnErrorReport getErrorReport() { + return errorReport; + } + + public void setProxyFqdn(String proxyFqdnIn) { + this.proxyFqdn = proxyFqdnIn; + } + + public String getProxyFqdn() { + return proxyFqdn; + } + + public Map getRegistryUrls() { + return registryUrls; + } + + public void setProxyMinion(MinionServer minionServerIn) { + this.proxyMinion = minionServerIn; + } + + public MinionServer getProxyMinion() { + return proxyMinion; + } + + public void setParentServer(Server server) { + this.parentServer = server; + } + + public void setProxyConfig(ProxyConfig proxyConfigIn) { + this.proxyConfig = proxyConfigIn; + } + + public Server getParentServer() { + return parentServer; + } + + public ProxyConfig getProxyConfig() { + return proxyConfig; + } + + public SystemManager getSystemManager() { + return systemManager; + } + + public User getUser() { + return user; + } + + public void setProxyConfigFiles(Map proxyConfigFilesIn) { + this.proxyConfigFiles = proxyConfigFilesIn; + } + + public Map getProxyConfigFiles() { + return proxyConfigFiles; + } + + public String getRootCA() { + return rootCA; + } + + public void setRootCA(String rootCAIn) { + rootCA = rootCAIn; + } + + public List getIntermediateCAs() { + return intermediateCAs; + } + + public void setIntermediateCAs(List intermediateCAsIn) { + intermediateCAs = intermediateCAsIn; + } + + public String getProxyCert() { + return proxyCert; + } + + public void setProxyCert(String proxyCertIn) { + proxyCert = proxyCertIn; + } + + public String getProxyKey() { + return proxyKey; + } + + public void setProxyKey(String proxyKeyIn) { + proxyKey = proxyKeyIn; + } + + public Pillar getPillar() { + return pillar; + } + + public void setPillar(Pillar pillarIn) { + pillar = pillarIn; + } + + public SaltApi getSaltApi() { + return saltApi; + } +} + diff --git a/java/code/src/com/suse/proxy/update/ProxyConfigUpdateContextHandler.java b/java/code/src/com/suse/proxy/update/ProxyConfigUpdateContextHandler.java new file mode 100644 index 000000000000..4a45318c385b --- /dev/null +++ b/java/code/src/com/suse/proxy/update/ProxyConfigUpdateContextHandler.java @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2025 SUSE LLC + * + * This software is licensed to you under the GNU General Public License, + * version 2 (GPLv2). There is NO WARRANTY for this software, express or + * implied, including the implied warranties of MERCHANTABILITY or FITNESS + * FOR A PARTICULAR PURPOSE. You should have received a copy of GPLv2 + * along with this software; if not, see + * http://www.gnu.org/licenses/old-licenses/gpl-2.0.txt. + * + * Red Hat trademarks are not licensed under GPLv2. No permission is + * granted to use or replicate Red Hat trademarks that are incorporated + * in this software or its documentation. + */ + +package com.suse.proxy.update; + +public interface ProxyConfigUpdateContextHandler { + /** + * Handles a step in saving a proxy configuration + * @param context the context + */ + void handle(ProxyConfigUpdateContext context); +} diff --git a/java/code/src/com/suse/proxy/update/ProxyConfigUpdateFileAcquisitor.java b/java/code/src/com/suse/proxy/update/ProxyConfigUpdateFileAcquisitor.java new file mode 100644 index 000000000000..eae13d09195b --- /dev/null +++ b/java/code/src/com/suse/proxy/update/ProxyConfigUpdateFileAcquisitor.java @@ -0,0 +1,104 @@ +/* + * Copyright (c) 2025 SUSE LLC + * + * This software is licensed to you under the GNU General Public License, + * version 2 (GPLv2). There is NO WARRANTY for this software, express or + * implied, including the implied warranties of MERCHANTABILITY or FITNESS + * FOR A PARTICULAR PURPOSE. You should have received a copy of GPLv2 + * along with this software; if not, see + * http://www.gnu.org/licenses/old-licenses/gpl-2.0.txt. + * + * Red Hat trademarks are not licensed under GPLv2. No permission is + * granted to use or replicate Red Hat trademarks that are incorporated + * in this software or its documentation. + */ + +package com.suse.proxy.update; + + +import static com.suse.utils.Predicates.isAbsent; +import static com.suse.utils.Predicates.isProvided; +import static java.lang.String.format; + +import com.suse.manager.ssl.SSLCertGenerationException; +import com.suse.manager.ssl.SSLCertManager; +import com.suse.manager.ssl.SSLCertPair; +import com.suse.manager.webui.utils.gson.ProxyConfigUpdateJson; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +import java.util.Map; + +/** + * Retrieves the proxy configuration files + */ +public class ProxyConfigUpdateFileAcquisitor implements ProxyConfigUpdateContextHandler { + + private static final Logger LOG = LogManager.getLogger(ProxyConfigUpdateFileAcquisitor.class); + private static final Map EXPECTED_FILE_CONFIGURATIONS = Map.of( + "server", new String[]{}, + "ca_crt", new String[]{}, + "proxy_fqdn", new String[]{}, + "max_cache_size_mb", new String[]{}, + "server_version", new String[]{}, + "email", new String[]{}, + "httpd", new String[]{"system_id", "server_crt", "server_key"}, + "ssh", new String[]{"server_ssh_key_pub", "server_ssh_push", "server_ssh_push_pub"} + ); + + @Override + public void handle(ProxyConfigUpdateContext context) { + ProxyConfigUpdateJson request = context.getRequest(); + + try { + context.setProxyConfigFiles( + context.getSystemManager().createProxyContainerConfigFiles( + context.getUser(), + context.getProxyFqdn(), + request.getProxyPort(), + request.getParentFqdn(), + Long.valueOf(request.getMaxCache()) * 1024L, + request.getEmail(), + context.getRootCA(), + context.getIntermediateCAs(), + new SSLCertPair(context.getProxyCert(), context.getProxyKey()), + null, null, null, new SSLCertManager()) + ); + + if (isAbsent(context.getProxyConfigFiles())) { + context.getErrorReport().register("proxy container configuration files were not created"); + LOG.debug("proxy container configuration files were not created"); + return; + } + + for (Map.Entry e : EXPECTED_FILE_CONFIGURATIONS.entrySet()) { + String firstLevelEntry = e.getKey(); + if (!context.getProxyConfigFiles().containsKey(firstLevelEntry)) { + String format = format("proxy container configuration did not generate required entry: %s", firstLevelEntry); + context.getErrorReport().register(format); + LOG.debug(format); + continue; + } + + String[] secondLevelEntries = e.getValue(); + if (isProvided(secondLevelEntries)) { + Map secondLevelMap = (Map) context.getProxyConfigFiles().get(firstLevelEntry); + for (String secondLevelEntry : secondLevelEntries) { + if (!secondLevelMap.containsKey(secondLevelEntry)) { + String format = format("proxy container configuration did not generate required entry: %s > %s", firstLevelEntry, secondLevelEntry); + context.getErrorReport().register(format); + LOG.debug(format); + } + } + } + } + } + catch (SSLCertGenerationException e) { + LOG.error("Failed to create proxy container configuration", e); + context.getErrorReport().register("Failed to create proxy container configuration"); + } + + } + +} diff --git a/java/code/src/com/suse/proxy/update/ProxyConfigUpdateRegistryPreConditions.java b/java/code/src/com/suse/proxy/update/ProxyConfigUpdateRegistryPreConditions.java new file mode 100644 index 000000000000..0ab0995287df --- /dev/null +++ b/java/code/src/com/suse/proxy/update/ProxyConfigUpdateRegistryPreConditions.java @@ -0,0 +1,61 @@ +/* + * Copyright (c) 2025 SUSE LLC + * + * This software is licensed to you under the GNU General Public License, + * version 2 (GPLv2). There is NO WARRANTY for this software, express or + * implied, including the implied warranties of MERCHANTABILITY or FITNESS + * FOR A PARTICULAR PURPOSE. You should have received a copy of GPLv2 + * along with this software; if not, see + * http://www.gnu.org/licenses/old-licenses/gpl-2.0.txt. + * + * Red Hat trademarks are not licensed under GPLv2. No permission is + * granted to use or replicate Red Hat trademarks that are incorporated + * in this software or its documentation. + */ + +package com.suse.proxy.update; + + +import static com.suse.proxy.ProxyConfigUtils.SOURCE_MODE_REGISTRY; +import static java.lang.String.format; + +import com.suse.manager.api.ParseException; +import com.suse.proxy.ProxyContainerImagesEnum; +import com.suse.proxy.ProxyRegistryUtils; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +/** + * Verifies registry sources exist and are reachable + */ +public class ProxyConfigUpdateRegistryPreConditions implements ProxyConfigUpdateContextHandler { + + private static final Logger LOG = LogManager.getLogger(ProxyConfigUpdateRegistryPreConditions.class); + + @Override + public void handle(ProxyConfigUpdateContext context) { + if (!SOURCE_MODE_REGISTRY.equals(context.getRequest().getSourceMode())) { + return; + } + + for (ProxyContainerImagesEnum proxyImage : ProxyContainerImagesEnum.values()) { + if (!context.getRegistryUrls().containsKey(proxyImage)) { + String noRegistryUrlMessage = format("No registry URL provided for image %s", proxyImage); + LOG.debug(noRegistryUrlMessage); + context.getErrorReport().register(noRegistryUrlMessage); + continue; + } + try { + // Testing access by retrieving the tags + ProxyRegistryUtils.getTags(context.getRegistryUrls().get(proxyImage)); + } + catch (ParseException parseException) { + LOG.debug("Failed to get tags from registry URL: {}", context.getRegistryUrls().get(proxyImage)); + context.getErrorReport().register("Failed to get tags from registry URL: " + context.getRegistryUrls().get(proxyImage)); + } + } + + } + +} diff --git a/java/code/src/com/suse/proxy/update/ProxyConfigUpdateSavePillars.java b/java/code/src/com/suse/proxy/update/ProxyConfigUpdateSavePillars.java new file mode 100644 index 000000000000..6ee6945aec03 --- /dev/null +++ b/java/code/src/com/suse/proxy/update/ProxyConfigUpdateSavePillars.java @@ -0,0 +1,86 @@ +/* + * Copyright (c) 2025 SUSE LLC + * + * This software is licensed to you under the GNU General Public License, + * version 2 (GPLv2). There is NO WARRANTY for this software, express or + * implied, including the implied warranties of MERCHANTABILITY or FITNESS + * FOR A PARTICULAR PURPOSE. You should have received a copy of GPLv2 + * along with this software; if not, see + * http://www.gnu.org/licenses/old-licenses/gpl-2.0.txt. + * + * Red Hat trademarks are not licensed under GPLv2. No permission is + * granted to use or replicate Red Hat trademarks that are incorporated + * in this software or its documentation. + */ + +package com.suse.proxy.update; + +import static com.suse.proxy.ProxyConfigUtils.EMAIL_FIELD; +import static com.suse.proxy.ProxyConfigUtils.INTERMEDIATE_CAS_FIELD; +import static com.suse.proxy.ProxyConfigUtils.MAX_CACHE_FIELD; +import static com.suse.proxy.ProxyConfigUtils.PARENT_FQDN_FIELD; +import static com.suse.proxy.ProxyConfigUtils.PILLAR_REGISTRY_ENTRY; +import static com.suse.proxy.ProxyConfigUtils.PILLAR_REGISTRY_TAG_ENTRY; +import static com.suse.proxy.ProxyConfigUtils.PILLAR_REGISTRY_URL_ENTRY; +import static com.suse.proxy.ProxyConfigUtils.PROXY_CERT_FIELD; +import static com.suse.proxy.ProxyConfigUtils.PROXY_FQDN_FIELD; +import static com.suse.proxy.ProxyConfigUtils.PROXY_KEY_FIELD; +import static com.suse.proxy.ProxyConfigUtils.PROXY_PILLAR_CATEGORY; +import static com.suse.proxy.ProxyConfigUtils.PROXY_PORT_FIELD; +import static com.suse.proxy.ProxyConfigUtils.ROOT_CA_FIELD; +import static com.suse.proxy.ProxyConfigUtils.SOURCE_MODE_REGISTRY; +import static com.suse.proxy.ProxyConfigUtils.USE_CERTS_MODE_KEEP; + +import com.redhat.rhn.common.hibernate.HibernateFactory; +import com.redhat.rhn.domain.server.MinionServer; +import com.redhat.rhn.domain.server.Pillar; + +import com.suse.manager.webui.utils.gson.ProxyConfigUpdateJson; +import com.suse.proxy.ProxyContainerImagesEnum; +import com.suse.proxy.RegistryUrl; + +import java.util.HashMap; +import java.util.Map; + +/** + * Handles the saving of the pillars + */ +public class ProxyConfigUpdateSavePillars implements ProxyConfigUpdateContextHandler { + + @Override + public void handle(ProxyConfigUpdateContext context) { + ProxyConfigUpdateJson request = context.getRequest(); + + MinionServer proxyMinion = context.getProxyMinion(); + Pillar pillar = proxyMinion.getPillarByCategory(PROXY_PILLAR_CATEGORY).orElseGet(() -> + new Pillar(PROXY_PILLAR_CATEGORY, new HashMap<>(), proxyMinion) + ); + + pillar.getPillar().clear(); + + pillar.add(PROXY_FQDN_FIELD, context.getProxyFqdn()); + pillar.add(PARENT_FQDN_FIELD, request.getParentFqdn()); + pillar.add(PROXY_PORT_FIELD, request.getProxyPort()); + pillar.add(MAX_CACHE_FIELD, request.getMaxCache()); + pillar.add(EMAIL_FIELD, request.getEmail()); + + pillar.add(ROOT_CA_FIELD, context.getRootCA()); + pillar.add(INTERMEDIATE_CAS_FIELD, context.getIntermediateCAs()); + pillar.add(PROXY_CERT_FIELD, context.getProxyCert()); + pillar.add(PROXY_KEY_FIELD, context.getProxyKey()); + + if (SOURCE_MODE_REGISTRY.equals(request.getSourceMode())) { + Map> registryEntries = new HashMap<>(); + for (ProxyContainerImagesEnum proxyContainerImage : ProxyContainerImagesEnum.values()) { + RegistryUrl registryUrl = context.getRegistryUrls().get(proxyContainerImage); + Map registryEntry = new HashMap<>(); + registryEntry.put(PILLAR_REGISTRY_URL_ENTRY, registryUrl.getRegistry()); + registryEntry.put(PILLAR_REGISTRY_TAG_ENTRY, registryUrl.getTag()); + registryEntries.put(proxyContainerImage.getImageName(), registryEntry); + } + pillar.add(PILLAR_REGISTRY_ENTRY, registryEntries); + } + HibernateFactory.getSession().save(pillar); + context.setPillar(pillar); + } +} diff --git a/java/code/src/com/suse/proxy/update/ProxyConfigUpdateValidation.java b/java/code/src/com/suse/proxy/update/ProxyConfigUpdateValidation.java new file mode 100644 index 000000000000..240ff560533c --- /dev/null +++ b/java/code/src/com/suse/proxy/update/ProxyConfigUpdateValidation.java @@ -0,0 +1,165 @@ +/* + * Copyright (c) 2025 SUSE LLC + * + * This software is licensed to you under the GNU General Public License, + * version 2 (GPLv2). There is NO WARRANTY for this software, express or + * implied, including the implied warranties of MERCHANTABILITY or FITNESS + * FOR A PARTICULAR PURPOSE. You should have received a copy of GPLv2 + * along with this software; if not, see + * http://www.gnu.org/licenses/old-licenses/gpl-2.0.txt. + * + * Red Hat trademarks are not licensed under GPLv2. No permission is + * granted to use or replicate Red Hat trademarks that are incorporated + * in this software or its documentation. + */ + +package com.suse.proxy.update; + +import static com.suse.proxy.ProxyConfigUtils.EMAIL_FIELD; +import static com.suse.proxy.ProxyConfigUtils.MAX_CACHE_FIELD; +import static com.suse.proxy.ProxyConfigUtils.PARENT_FQDN_FIELD; +import static com.suse.proxy.ProxyConfigUtils.PROXY_CERT_FIELD; +import static com.suse.proxy.ProxyConfigUtils.PROXY_KEY_FIELD; +import static com.suse.proxy.ProxyConfigUtils.PROXY_PORT_FIELD; +import static com.suse.proxy.ProxyConfigUtils.REGISTRY_BASE_TAG; +import static com.suse.proxy.ProxyConfigUtils.REGISTRY_BASE_URL; +import static com.suse.proxy.ProxyConfigUtils.REGISTRY_MODE; +import static com.suse.proxy.ProxyConfigUtils.REGISTRY_MODE_ADVANCED; +import static com.suse.proxy.ProxyConfigUtils.REGISTRY_MODE_SIMPLE; +import static com.suse.proxy.ProxyConfigUtils.ROOT_CA_FIELD; +import static com.suse.proxy.ProxyConfigUtils.SERVER_ID_FIELD; +import static com.suse.proxy.ProxyConfigUtils.SOURCE_MODE_FIELD; +import static com.suse.proxy.ProxyConfigUtils.SOURCE_MODE_REGISTRY; +import static com.suse.proxy.ProxyConfigUtils.SOURCE_MODE_RPM; +import static com.suse.proxy.ProxyConfigUtils.USE_CERTS_MODE_KEEP; +import static com.suse.proxy.ProxyContainerImagesEnum.PROXY_HTTPD; +import static com.suse.proxy.ProxyContainerImagesEnum.PROXY_SALT_BROKER; +import static com.suse.proxy.ProxyContainerImagesEnum.PROXY_SQUID; +import static com.suse.proxy.ProxyContainerImagesEnum.PROXY_SSH; +import static com.suse.proxy.ProxyContainerImagesEnum.PROXY_TFTPD; +import static com.suse.utils.Predicates.isAbsent; +import static java.lang.String.format; + +import com.redhat.rhn.common.RhnErrorReport; + +import com.suse.manager.webui.utils.gson.ProxyConfigUpdateJson; +import com.suse.proxy.model.ProxyConfig; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +import java.util.regex.Pattern; + +/** + * Executes basic validation over the request inputs. + * Checks if the required fields are present and if the values are valid. + */ +public class ProxyConfigUpdateValidation implements ProxyConfigUpdateContextHandler { + private static final Logger LOG = LogManager.getLogger(ProxyConfigUpdateValidation.class); + private static final Pattern FQDN_PATTERN = Pattern.compile("^[A-Za-z0-9-]++(?:\\.[A-Za-z0-9-]++)*+$"); + private static final String NOT_FOUND_ON_CURRENT_PROXY_CONFIGURATION_MESSAGE = "%s not found on current proxy configuration"; + + private RhnErrorReport errorReport; + + @Override + public void handle(ProxyConfigUpdateContext context) { + ProxyConfigUpdateJson request = context.getRequest(); + this.errorReport = context.getErrorReport(); + + if (!registerIfMissing(request.getServerId(), SERVER_ID_FIELD) && isAbsent(context.getProxyFqdn())) { + errorReport.register("proxyFQDN for the server was not resolved"); + LOG.debug("Proxy FQDN for the server {} was not resolved", request.getServerId()); + } + + String parentFqdn = request.getParentFqdn(); + if (!registerIfMissing(parentFqdn, PARENT_FQDN_FIELD) && !FQDN_PATTERN.matcher(parentFqdn).matches()) { + errorReport.register("parentFQDN is invalid"); + } + registerIfMissing(request.getProxyPort(), PROXY_PORT_FIELD); + registerIfMissing(request.getMaxCache(), MAX_CACHE_FIELD); + registerIfMissing(request.getEmail(), EMAIL_FIELD); + validateCertificates(context); + + if (!registerIfMissing(request.getSourceMode(), SOURCE_MODE_FIELD)) { + validateSourceMode(request); + } + } + + private void validateCertificates(ProxyConfigUpdateContext context) { + ProxyConfigUpdateJson request = context.getRequest(); + if (USE_CERTS_MODE_KEEP.equals(request.getUseCertsMode())) { + ProxyConfig proxyConfig = context.getProxyConfig(); + if (isAbsent(proxyConfig)) { + errorReport.register("No current proxy configuration found to keep certificates"); + return; + } + if (isAbsent(context.getRootCA())) { + errorReport.register(String.format(NOT_FOUND_ON_CURRENT_PROXY_CONFIGURATION_MESSAGE, ROOT_CA_FIELD)); + } + if (isAbsent(context.getProxyCert())) { + errorReport.register(String.format(NOT_FOUND_ON_CURRENT_PROXY_CONFIGURATION_MESSAGE, PROXY_CERT_FIELD)); + } + if (isAbsent(context.getProxyKey())) { + errorReport.register(String.format(NOT_FOUND_ON_CURRENT_PROXY_CONFIGURATION_MESSAGE, PROXY_KEY_FIELD)); + } + return; + } + registerIfMissing(context.getRootCA(), ROOT_CA_FIELD); + registerIfMissing(context.getProxyCert(), PROXY_CERT_FIELD); + registerIfMissing(context.getProxyKey(), PROXY_KEY_FIELD); + } + + private void validateSourceMode(ProxyConfigUpdateJson request) { + switch (request.getSourceMode()) { + case SOURCE_MODE_REGISTRY: + if (!registerIfMissing(request.getRegistryMode(), REGISTRY_MODE)) { + validateSourceRegistryMode(request); + } + break; + case SOURCE_MODE_RPM: + break; + default: + errorReport.register(format("sourceMode %s is invalid. Must be either 'registry' or 'rpm'", request.getSourceMode())); + } + } + + private void validateSourceRegistryMode(ProxyConfigUpdateJson request) { + switch (request.getRegistryMode()) { + case REGISTRY_MODE_SIMPLE: + registerIfMissing(request.getRegistryBaseURL(), REGISTRY_BASE_URL); + registerIfMissing(request.getRegistryBaseTag(), REGISTRY_BASE_TAG); + return; + case REGISTRY_MODE_ADVANCED: + registerIfMissing(request.getRegistryHttpdURL(), PROXY_HTTPD.getUrlField()); + registerIfMissing(request.getRegistryHttpdTag(), PROXY_HTTPD.getTagField()); + registerIfMissing(request.getRegistrySaltbrokerURL(), PROXY_SALT_BROKER.getUrlField()); + registerIfMissing(request.getRegistrySaltbrokerTag(), PROXY_SALT_BROKER.getTagField()); + registerIfMissing(request.getRegistrySquidURL(), PROXY_SQUID.getUrlField()); + registerIfMissing(request.getRegistrySquidTag(), PROXY_SQUID.getTagField()); + registerIfMissing(request.getRegistrySshURL(), PROXY_SSH.getUrlField()); + registerIfMissing(request.getRegistrySshTag(), PROXY_SSH.getTagField()); + registerIfMissing(request.getRegistryTftpdURL(), PROXY_TFTPD.getUrlField()); + registerIfMissing(request.getRegistryTftpdTag(), PROXY_TFTPD.getTagField()); + return; + default: + errorReport.register(format("sourceRegistryMode %s is invalid. Must be either 'simple' or 'advanced'", request.getRegistryMode())); + } + + } + + /** + * Validates and register an error if a given value is not null or empty + * + * @param value the value to validate + * @param field the field name + * @return true if the value is missing, false otherwise + */ + public boolean registerIfMissing(Object value, String field) { + if (isAbsent(value)) { + errorReport.register(String.format("%s is required", field)); + return true; + } + return false; + } + +} diff --git a/java/code/src/com/suse/rest/RestClient.java b/java/code/src/com/suse/rest/RestClient.java new file mode 100644 index 000000000000..317b6f67c8ec --- /dev/null +++ b/java/code/src/com/suse/rest/RestClient.java @@ -0,0 +1,152 @@ +/* + * Copyright (c) 2025 SUSE LLC + * + * This software is licensed to you under the GNU General Public License, + * version 2 (GPLv2). There is NO WARRANTY for this software, express or + * implied, including the implied warranties of MERCHANTABILITY or FITNESS + * FOR A PARTICULAR PURPOSE. You should have received a copy of GPLv2 + * along with this software; if not, see + * http://www.gnu.org/licenses/old-licenses/gpl-2.0.txt. + * + * Red Hat trademarks are not licensed under GPLv2. No permission is + * granted to use or replicate Red Hat trademarks that are incorporated + * in this software or its documentation. + */ + +package com.suse.rest; + +import static com.suse.utils.Predicates.allProvided; +import static com.suse.utils.Predicates.isProvided; + +import com.redhat.rhn.common.util.http.HttpClientAdapter; + +import com.google.gson.Gson; + +import org.apache.http.Header; +import org.apache.http.HttpResponse; +import org.apache.http.client.methods.HttpDelete; +import org.apache.http.client.methods.HttpEntityEnclosingRequestBase; +import org.apache.http.client.methods.HttpGet; +import org.apache.http.client.methods.HttpPatch; +import org.apache.http.client.methods.HttpPost; +import org.apache.http.client.methods.HttpPut; +import org.apache.http.client.methods.HttpRequestBase; +import org.apache.http.entity.StringEntity; +import org.apache.http.util.EntityUtils; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Base64; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * Class to execute a REST request. + * Designed to provide and accept JSON. + */ +public class RestClient { + + private static final RestClient INSTANCE = new RestClient(); + + private RestClient() { + } + + /** + * Gets the single instance of SCCRequestFactory. + * + * @return single instance of SCCRequestFactory + */ + public static RestClient getInstance() { + return INSTANCE; + } + + + /** + * Executes a REST request. + * + * @param restRequest the request to execute + * @return the response + */ + public RestResponse execute(RestRequest restRequest) { + try { + // Prep the request + HttpRequestBase request = null; + switch (restRequest.getMethod()) { + case GET: + request = new HttpGet(restRequest.getUrl()); + break; + case POST: + request = new HttpPost(restRequest.getUrl()); + break; + case PUT: + request = new HttpPut(restRequest.getUrl()); + break; + case DELETE: + request = new HttpDelete(restRequest.getUrl()); + break; + case PATCH: + request = new HttpPatch(restRequest.getUrl()); + break; + default: + throw new RestClientException("HTTP method not supported: " + restRequest.getMethod()); + } + + HttpClientAdapter httpClient = new HttpClientAdapter(); + + request.addHeader("Content-Type", "application/json"); + request.addHeader("Accept", "application/json"); + + + if (restRequest.getRequestAuthType() == RestRequestAuthEnum.BEARER) { + request.addHeader("Authorization", "Bearer " + restRequest.getBearerToken()); + } + else if (restRequest.getRequestAuthType() == RestRequestAuthEnum.BASIC) { + String basicAuth = "Basic " + + Base64.getEncoder().encodeToString((restRequest.getBasicUser() + ":" + restRequest.getBasicPassword()).getBytes()); + request.setHeader("Authorization", basicAuth); + } + + if (request instanceof HttpEntityEnclosingRequestBase httpEntityEnclosingRequestBase) { + Object body = restRequest.getBody(); + if (body != null) { + String jsonBody = new Gson().toJson(body); + httpEntityEnclosingRequestBase.setEntity( + new StringEntity(jsonBody, StandardCharsets.UTF_8) + ); + } + } + + // Execute request + HttpResponse response = httpClient.executeRequest(request); + + // Handle the response + int responseCode = response.getStatusLine().getStatusCode(); + + String body = null; + if (allProvided(response, response.getEntity())) { + body = EntityUtils.toString(response.getEntity()); + } + + Map> responseHeaders = new HashMap<>(); + for (Header header : response.getAllHeaders()) { + String headerName = header.getName(); + String headerValue = header.getValue(); + responseHeaders + .computeIfAbsent(headerName, k -> new ArrayList<>()) + .add(headerValue); + } + + return new RestResponse( + responseCode, + responseHeaders, + body + ); + } + catch (IOException e) { + throw new RestClientException(e); + } + } + +} diff --git a/java/code/src/com/suse/rest/RestClientException.java b/java/code/src/com/suse/rest/RestClientException.java new file mode 100644 index 000000000000..5ebe57c4903d --- /dev/null +++ b/java/code/src/com/suse/rest/RestClientException.java @@ -0,0 +1,96 @@ +/* + * Copyright (c) 2025 SUSE LLC + * + * This software is licensed to you under the GNU General Public License, + * version 2 (GPLv2). There is NO WARRANTY for this software, express or + * implied, including the implied warranties of MERCHANTABILITY or FITNESS + * FOR A PARTICULAR PURPOSE. You should have received a copy of GPLv2 + * along with this software; if not, see + * http://www.gnu.org/licenses/old-licenses/gpl-2.0.txt. + * + * Red Hat trademarks are not licensed under GPLv2. No permission is + * granted to use or replicate Red Hat trademarks that are incorporated + * in this software or its documentation. + */ +package com.suse.rest; + +/** + * Exception to be thrown in case of problems with Rest Calls. + */ +public class RestClientException extends RuntimeException { + + private final int httpStatusCode; + private final String httpRequestURI; + + /** + * Constructor expecting a custom cause. + * @param cause the cause + */ + public RestClientException(Throwable cause) { + this(0, null, cause); + } + + /** + * Constructor expecting a custom cause. + * @param statusCode http status code + * @param cause the cause + */ + public RestClientException(int statusCode, Throwable cause) { + this(statusCode, null, cause); + } + + /** + * Constructor expecting a custom cause. + * @param statusCode http status code + * @param uri http request uri + * @param cause the cause + */ + public RestClientException(int statusCode, String uri, Throwable cause) { + super(cause); + httpStatusCode = statusCode; + httpRequestURI = uri; + } + + /** + * Constructor expecting a custom message. + * @param message the message + */ + public RestClientException(String message) { + this(0, null, message); + } + + /** + * Constructor expecting a custom message. + * @param statusCode http status code + * @param message the message + */ + public RestClientException(int statusCode, String message) { + this(statusCode, null, message); + } + + /** + * Constructor expecting a custom message. + * @param statusCode http status code + * @param uri http request uri + * @param message the message + */ + public RestClientException(int statusCode, String uri, String message) { + super(message); + httpStatusCode = statusCode; + httpRequestURI = uri; + } + + /** + * @return Returns the httpStatusCode. + */ + public int getHttpStatusCode() { + return httpStatusCode; + } + + /** + * @return Returns the httpRequestURI. + */ + public String getHttpRequestURI() { + return httpRequestURI; + } +} diff --git a/java/code/src/com/suse/rest/RestRequest.java b/java/code/src/com/suse/rest/RestRequest.java new file mode 100644 index 000000000000..5dac55cf3a51 --- /dev/null +++ b/java/code/src/com/suse/rest/RestRequest.java @@ -0,0 +1,90 @@ +/* + * Copyright (c) 2025 SUSE LLC + * + * This software is licensed to you under the GNU General Public License, + * version 2 (GPLv2). There is NO WARRANTY for this software, express or + * implied, including the implied warranties of MERCHANTABILITY or FITNESS + * FOR A PARTICULAR PURPOSE. You should have received a copy of GPLv2 + * along with this software; if not, see + * http://www.gnu.org/licenses/old-licenses/gpl-2.0.txt. + * + * Red Hat trademarks are not licensed under GPLv2. No permission is + * granted to use or replicate Red Hat trademarks that are incorporated + * in this software or its documentation. + */ + +package com.suse.rest; + +import java.util.Map; + +public class RestRequest { + + private final RestRequestMethodEnum method; + private final String url; + private final RestRequestAuthEnum requestAuthType; + private final Object body; + private final Map headers; + private final Map pathParams; + private final Map queryParams; + private final String bearerToken; + private final String basicUser; + private final String basicPassword; + + /** + * Constructor to create a Request instance. + * + * @param builder the input builder + */ + public RestRequest(RestRequestBuilder builder) { + this.method = builder.method; + this.url = builder.url; + this.requestAuthType = builder.requestAuth; + this.body = builder.body; + this.headers = builder.headers; + this.pathParams = builder.pathParams; + this.queryParams = builder.queryParams; + this.bearerToken = builder.bearerToken; + this.basicUser = builder.basicUser; + this.basicPassword = builder.basicPassword; + } + + public RestRequestMethodEnum getMethod() { + return method; + } + + public String getUrl() { + return url; + } + + public RestRequestAuthEnum getRequestAuthType() { + return requestAuthType; + } + + public Object getBody() { + return body; + } + + public Map getHeaders() { + return headers; + } + + public Map getPathParams() { + return pathParams; + } + + public Map getQueryParams() { + return queryParams; + } + + public String getBearerToken() { + return bearerToken; + } + + public String getBasicUser() { + return basicUser; + } + + public String getBasicPassword() { + return basicPassword; + } +} diff --git a/java/code/src/com/suse/rest/RestRequestAuthEnum.java b/java/code/src/com/suse/rest/RestRequestAuthEnum.java new file mode 100644 index 000000000000..61828df8e02e --- /dev/null +++ b/java/code/src/com/suse/rest/RestRequestAuthEnum.java @@ -0,0 +1,25 @@ +/* + * Copyright (c) 2025 SUSE LLC + * + * This software is licensed to you under the GNU General Public License, + * version 2 (GPLv2). There is NO WARRANTY for this software, express or + * implied, including the implied warranties of MERCHANTABILITY or FITNESS + * FOR A PARTICULAR PURPOSE. You should have received a copy of GPLv2 + * along with this software; if not, see + * http://www.gnu.org/licenses/old-licenses/gpl-2.0.txt. + * + * Red Hat trademarks are not licensed under GPLv2. No permission is + * granted to use or replicate Red Hat trademarks that are incorporated + * in this software or its documentation. + */ + +package com.suse.rest; + +/** + * Enum to represent the type of authentication required for a request. + */ +public enum RestRequestAuthEnum { + NONE, + BEARER, + BASIC; +} diff --git a/java/code/src/com/suse/rest/RestRequestBuilder.java b/java/code/src/com/suse/rest/RestRequestBuilder.java new file mode 100644 index 000000000000..b5e44cc042f9 --- /dev/null +++ b/java/code/src/com/suse/rest/RestRequestBuilder.java @@ -0,0 +1,164 @@ +/* + * Copyright (c) 2025 SUSE LLC + * + * This software is licensed to you under the GNU General Public License, + * version 2 (GPLv2). There is NO WARRANTY for this software, express or + * implied, including the implied warranties of MERCHANTABILITY or FITNESS + * FOR A PARTICULAR PURPOSE. You should have received a copy of GPLv2 + * along with this software; if not, see + * http://www.gnu.org/licenses/old-licenses/gpl-2.0.txt. + * + * Red Hat trademarks are not licensed under GPLv2. No permission is + * granted to use or replicate Red Hat trademarks that are incorporated + * in this software or its documentation. + */ + +package com.suse.rest; + +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.util.HashMap; +import java.util.Map; + +/** + * Class to build and execute a request. + */ +public class RestRequestBuilder { + + final RestRequestMethodEnum method; + final String url; + RestRequestAuthEnum requestAuth = RestRequestAuthEnum.NONE; + Object body; + Map headers = new HashMap<>(); + Map pathParams = new HashMap<>(); + Map queryParams = new HashMap<>(); + String bearerToken; + String basicUser; + String basicPassword; + + /** + * Constructor to create a RequestBuilder instance. + * + * @param methodIn the request method + * @param urlIn the URL + */ + public RestRequestBuilder(RestRequestMethodEnum methodIn, String urlIn) { + method = methodIn; + url = urlIn; + } + + /** + * Sets the body of the request. + * + * @param bodyIn the body of the request + * @return the RequestBuilder instance + */ + public RestRequestBuilder body(Object bodyIn) { + this.body = bodyIn; + return this; + } + + + /** + * Add a path parameter to the request. + * + * @param name the name of the path parameter + * @param value the value of the path parameter + * @return the RequestBuilder instance + */ + public RestRequestBuilder pathParam(String name, String value) { + this.pathParams.put(name, value); + return this; + } + + /** + * Add a header to the request. + * + * @param key the key of the header + * @param value the value of the header + * @return the RequestBuilder instance + */ + public RestRequestBuilder header(String key, String value) { + headers.put(key, value); + return this; + } + + /** + * Add a query parameter to the request. + * + * @param name the name of the query parameter + * @param value the value of the query parameter + * @return the RequestBuilder instance + */ + public RestRequestBuilder queryParam(String name, String value) { + this.queryParams.put(name, value); + return this; + } + + /** + * Sets the bearer token for the request. + * + * @param bearerTokenIn the bearer token + * @return the RequestBuilder instance + */ + public RestRequestBuilder bearerToken(String bearerTokenIn) { + this.requestAuth = RestRequestAuthEnum.BEARER; + this.bearerToken = bearerTokenIn; + return this; + } + + /** + * Sets the basic authentication for the request. + * + * @param usernameIn the username + * @param passwordIn the password + * @return the RequestBuilder instance + */ + public RestRequestBuilder basicAuth(String usernameIn, String passwordIn) { + this.requestAuth = RestRequestAuthEnum.BASIC; + this.basicUser = usernameIn; + this.basicPassword = passwordIn; + return this; + } + + /** + * Builds the request. + * + * @return the RestRequest + */ + public RestRequest build() { + return new RestRequest(this); + } + + /** + * Builds the URL with path and query parameters. + * + * @return the URL + */ + public String buildUrl() { + String finalUrl = url; + for (Map.Entry entry : pathParams.entrySet()) { + finalUrl = finalUrl.replace( + "{" + entry.getKey() + "}", + URLEncoder.encode(entry.getValue(), StandardCharsets.UTF_8) + ); + } + + if (!queryParams.isEmpty()) { + StringBuilder queryBuilder = new StringBuilder(); + for (Map.Entry entry : queryParams.entrySet()) { + queryBuilder + .append(URLEncoder.encode(entry.getKey(), StandardCharsets.UTF_8)) + .append("=") + .append(URLEncoder.encode(entry.getValue(), StandardCharsets.UTF_8)) + .append("&"); + } + queryBuilder.setLength(queryBuilder.length() - 1); + finalUrl += (finalUrl.contains("?") ? "&" : "?") + queryBuilder; + } + + return finalUrl; + } + + +} diff --git a/java/code/src/com/suse/rest/RestRequestMethodEnum.java b/java/code/src/com/suse/rest/RestRequestMethodEnum.java new file mode 100644 index 000000000000..68613b7cde4d --- /dev/null +++ b/java/code/src/com/suse/rest/RestRequestMethodEnum.java @@ -0,0 +1,23 @@ +/* + * Copyright (c) 2025 SUSE LLC + * + * This software is licensed to you under the GNU General Public License, + * version 2 (GPLv2). There is NO WARRANTY for this software, express or + * implied, including the implied warranties of MERCHANTABILITY or FITNESS + * FOR A PARTICULAR PURPOSE. You should have received a copy of GPLv2 + * along with this software; if not, see + * http://www.gnu.org/licenses/old-licenses/gpl-2.0.txt. + * + * Red Hat trademarks are not licensed under GPLv2. No permission is + * granted to use or replicate Red Hat trademarks that are incorporated + * in this software or its documentation. + */ + +package com.suse.rest; + +/** + * Enum to represent the type of request method. + */ +public enum RestRequestMethodEnum { + GET, POST, PUT, DELETE, PATCH; +} \ No newline at end of file diff --git a/java/code/src/com/suse/rest/RestResponse.java b/java/code/src/com/suse/rest/RestResponse.java new file mode 100644 index 000000000000..2b9f1cd030de --- /dev/null +++ b/java/code/src/com/suse/rest/RestResponse.java @@ -0,0 +1,88 @@ +/* + * Copyright (c) 2025 SUSE LLC + * + * This software is licensed to you under the GNU General Public License, + * version 2 (GPLv2). There is NO WARRANTY for this software, express or + * implied, including the implied warranties of MERCHANTABILITY or FITNESS + * FOR A PARTICULAR PURPOSE. You should have received a copy of GPLv2 + * along with this software; if not, see + * http://www.gnu.org/licenses/old-licenses/gpl-2.0.txt. + * + * Red Hat trademarks are not licensed under GPLv2. No permission is + * granted to use or replicate Red Hat trademarks that are incorporated + * in this software or its documentation. + */ + +package com.suse.rest; + +import com.suse.manager.api.ParseException; + +import com.google.gson.Gson; +import com.google.gson.JsonSyntaxException; + +import java.util.List; +import java.util.Map; + +/** + * Class to represent a response from a request. + */ +public class RestResponse { + private final int statusCode; + private final Map> headers; + private final String body; + + /** + * Constructor to create a Response instance. + * + * @param statusCodeIn the input status code + * @param headersIn the input headers + * @param bodyIn the input content + */ + public RestResponse(int statusCodeIn, Map> headersIn, String bodyIn) { + statusCode = statusCodeIn; + body = bodyIn; + headers = headersIn; + } + + public int getStatusCode() { + return statusCode; + } + + public Map> getHeaders() { + return headers; + } + + public String getBody() { + return body; + } + + /** + * Parses the body content as a JSON element. + * + * @param type the type of the JSON element + * @return the parsed JSON element + * @param the type of the JSON element + * @throws ParseException if the body content is not a valid JSON + */ + public T getBodyAs(Class type) throws ParseException { + try { + return new Gson().fromJson(body, type); + } + catch (JsonSyntaxException e) { + throw new ParseException(e); + } + } + + @Override + public String toString() { + return "Response{" + + "statusCode=" + statusCode + + ", headers=" + headers + + ", content='" + body + '\'' + + '}'; + } + + public boolean isSuccessful() { + return statusCode >= 200 && statusCode < 300; + } +} diff --git a/java/code/webapp/WEB-INF/nav/system_detail.xml b/java/code/webapp/WEB-INF/nav/system_detail.xml index 4e021c6245d1..bbd09d80ef2a 100644 --- a/java/code/webapp/WEB-INF/nav/system_detail.xml +++ b/java/code/webapp/WEB-INF/nav/system_detail.xml @@ -213,6 +213,11 @@ /rhn/manager/systems/details/formulas + + /rhn/manager/systems/details/proxy-config + + + /rhn/manager/systems/details/ansible/control-node diff --git a/java/code/webapp/WEB-INF/pages/common/fragments/systems/system-header.jspf b/java/code/webapp/WEB-INF/pages/common/fragments/systems/system-header.jspf index a838c673741e..37dd9b487495 100644 --- a/java/code/webapp/WEB-INF/pages/common/fragments/systems/system-header.jspf +++ b/java/code/webapp/WEB-INF/pages/common/fragments/systems/system-header.jspf @@ -41,7 +41,9 @@ miscAcl="system_feature(ftr_system_grouping)" helpUrl="/docs/${rhn:getDocsLocale(pageContext)}/reference/systems/systems-menu.html" deletionUrl="/rhn/systems/details/DeleteConfirm.do?sid=${system.id}" - deletionType="system"> + deletionType="system" + convertProxyAcl="system_is_convertible_to_proxy()" + convertProxyUrl="/rhn/manager/systems/details/proxy-config?sid=${system.id}"> diff --git a/java/code/webapp/WEB-INF/struts-config.xml b/java/code/webapp/WEB-INF/struts-config.xml index 102176eaffbd..f281b8cc2f6c 100644 --- a/java/code/webapp/WEB-INF/struts-config.xml +++ b/java/code/webapp/WEB-INF/struts-config.xml @@ -899,6 +899,7 @@ + diff --git a/java/spacewalk-java.changes.rjpmestre.simplified-proxy-onboarding b/java/spacewalk-java.changes.rjpmestre.simplified-proxy-onboarding new file mode 100644 index 000000000000..648110a2b22b --- /dev/null +++ b/java/spacewalk-java.changes.rjpmestre.simplified-proxy-onboarding @@ -0,0 +1 @@ +- Add proxy onboarding feature diff --git a/schema/spacewalk/common/data/rhnActionType.sql b/schema/spacewalk/common/data/rhnActionType.sql index f21536479c95..20cf047764f2 100644 --- a/schema/spacewalk/common/data/rhnActionType.sql +++ b/schema/spacewalk/common/data/rhnActionType.sql @@ -1,4 +1,5 @@ -- +-- Copyright (c) 2015--2025 SUSE LLC -- Copyright (c) 2012--2014 Red Hat, Inc. -- -- This software is licensed to you under the GNU General Public License, @@ -70,5 +71,6 @@ insert into rhnActionType values (506, 'channels.subscribe', 'Subscribe to chann insert into rhnActionType values (521, 'ansible.playbook', 'Execute an Ansible playbook', 'N', 'N', 'N'); insert into rhnActionType values (523, 'coco.attestation', 'Confidential Compute Attestation', 'N', 'N', 'N'); insert into rhnActionType values (524, 'appstreams.configure', 'Configure AppStreams in a system', 'N', 'N', 'N'); +insert into rhnActionType values (525, 'proxy_configuration.apply', 'Apply a proxy configuration to a system', 'N', 'N', 'N'); commit; diff --git a/schema/spacewalk/common/data/rhnSGTypeBaseAddonCompat.sql b/schema/spacewalk/common/data/rhnSGTypeBaseAddonCompat.sql index 1870ca0d37fa..f0ae4ee0290d 100644 --- a/schema/spacewalk/common/data/rhnSGTypeBaseAddonCompat.sql +++ b/schema/spacewalk/common/data/rhnSGTypeBaseAddonCompat.sql @@ -1,4 +1,5 @@ -- +-- Copyright (c) 2009-2025 SUSE LLC -- Copyright (c) 2008 Red Hat, Inc. -- -- This software is licensed to you under the GNU General Public License, @@ -45,4 +46,8 @@ insert into rhnSGTypeBaseAddonCompat (base_id, addon_id) values (lookup_sg_type('foreign_entitled'), lookup_sg_type('peripheral_server')); +insert into rhnSGTypeBaseAddonCompat (base_id, addon_id) +values (lookup_sg_type('salt_entitled'), + lookup_sg_type('proxy_entitled')); + commit; diff --git a/schema/spacewalk/common/data/rhnServerGroupType.sql b/schema/spacewalk/common/data/rhnServerGroupType.sql index 6a2aa5e28857..dc359f0cb42e 100644 --- a/schema/spacewalk/common/data/rhnServerGroupType.sql +++ b/schema/spacewalk/common/data/rhnServerGroupType.sql @@ -1,4 +1,5 @@ -- +-- Copyright (c) 2014--2025 SUSE LLC -- Copyright (c) 2008--2013 Red Hat, Inc. -- -- This software is licensed to you under the GNU General Public License, @@ -98,3 +99,13 @@ insert into rhnServerGroupType ( id, label, name, permanent, is_base) ); commit; + +-- proxy_entitled type --------------------------------------------------- + +insert into rhnServerGroupType ( id, label, name, permanent, is_base) + values ( sequence_nextval('rhn_servergroup_type_seq'), + 'proxy_entitled', 'Proxy', + 'N', 'N' + ); + +commit; diff --git a/schema/spacewalk/common/data/rhnServerServerGroupArchCompat.sql b/schema/spacewalk/common/data/rhnServerServerGroupArchCompat.sql index cafa5c1c2a54..8ad69c29cdb3 100644 --- a/schema/spacewalk/common/data/rhnServerServerGroupArchCompat.sql +++ b/schema/spacewalk/common/data/rhnServerServerGroupArchCompat.sql @@ -1,4 +1,5 @@ -- +-- Copyright (c) 2016--2025 SUSE LLC -- Copyright (c) 2008--2015 Red Hat, Inc. -- -- This software is licensed to you under the GNU General Public License, @@ -937,4 +938,27 @@ insert into rhnServerServerGroupArchCompat ( server_arch_id, server_group_type ) values (lookup_server_arch('amd64-redhat-linux'), lookup_sg_type('peripheral_server')); +-- proxy_entitled compatibilities +DO $$ +DECLARE + loop_server_arch_id INT; + proxy_sg_type_id INT; +BEGIN + proxy_sg_type_id := lookup_sg_type('proxy_entitled'); + + FOR loop_server_arch_id IN + SELECT id FROM rhnserverarch + LOOP + INSERT INTO rhnServerServerGroupArchCompat (server_arch_id, server_group_type) + SELECT + loop_server_arch_id, + proxy_sg_type_id + WHERE NOT EXISTS ( + SELECT 1 + FROM rhnServerServerGroupArchCompat AS rs + WHERE rs.server_arch_id = loop_server_arch_id AND rs.server_group_type = proxy_sg_type_id + ); + END LOOP; +END $$; + commit; diff --git a/schema/spacewalk/postgres/procs/create_new_org.sql b/schema/spacewalk/postgres/procs/create_new_org.sql index 1563cb4b28c8..7c0f1c97e457 100644 --- a/schema/spacewalk/postgres/procs/create_new_org.sql +++ b/schema/spacewalk/postgres/procs/create_new_org.sql @@ -1,4 +1,5 @@ -- +-- Copyright (c) 2013--2025 SUSE LLC -- Copyright (c) 2008--2012 Red Hat, Inc. -- -- This software is licensed to you under the GNU General Public License, @@ -233,6 +234,14 @@ begin from rhnServerGroupType sgt where sgt.label = 'peripheral_server'; + + insert into rhnServerGroup + ( id, name, description, group_type, org_id ) + select nextval('rhn_server_group_id_seq'), sgt.name, sgt.name, + sgt.id, new_org_id + from rhnServerGroupType sgt + where sgt.label = 'proxy_entitled'; + insert into suseImageStore (id, label, uri, store_type_id, org_id) values ( nextval('suse_imgstore_id_seq'), diff --git a/schema/spacewalk/susemanager-schema.changes.rjpmestre.simplified-proxy-onboarding b/schema/spacewalk/susemanager-schema.changes.rjpmestre.simplified-proxy-onboarding new file mode 100644 index 000000000000..648110a2b22b --- /dev/null +++ b/schema/spacewalk/susemanager-schema.changes.rjpmestre.simplified-proxy-onboarding @@ -0,0 +1 @@ +- Add proxy onboarding feature diff --git a/schema/spacewalk/upgrade/susemanager-schema-5.1.2-to-susemanager-schema-5.1.3/010-proxy-entitled.sql b/schema/spacewalk/upgrade/susemanager-schema-5.1.2-to-susemanager-schema-5.1.3/010-proxy-entitled.sql new file mode 100644 index 000000000000..4c003145273f --- /dev/null +++ b/schema/spacewalk/upgrade/susemanager-schema-5.1.2-to-susemanager-schema-5.1.3/010-proxy-entitled.sql @@ -0,0 +1,72 @@ +-------------------------------------------------------------------------------- +-- rhnServerGroupType ---------------------------------------------------------- +-------------------------------------------------------------------------------- +INSERT INTO rhnServerGroupType( + id, + label, + name, + permanent, + is_base) +SELECT + sequence_nextval('rhn_servergroup_type_seq'), + 'proxy_entitled', + 'Proxy', + 'N', + 'N' +WHERE NOT EXISTS ( + SELECT 1 + FROM rhnServerGroupType + WHERE label = 'proxy_entitled' +); + +-------------------------------------------------------------------------------- +-- rhnServerGroup -------------------------------------------------------------- +-------------------------------------------------------------------------------- +INSERT INTO rhnServerGroup ( id, name, description, group_type, org_id ) +SELECT nextval('rhn_server_group_id_seq'), sgt.name, sgt.name, sgt.id, org.id +FROM rhnServerGroupType sgt, web_customer org +WHERE sgt.label = 'proxy_entitled' AND org.id NOT IN ( + SELECT sg.org_id from rhnServerGroup sg + WHERE sg.name = 'Proxy' +); + + +-------------------------------------------------------------------------------- +-- rhnSGTypeBaseAddonCompat ---------------------------------------------------- +-------------------------------------------------------------------------------- +INSERT INTO rhnSGTypeBaseAddonCompat(base_id, addon_id) +SELECT + lookup_sg_type('salt_entitled'), + lookup_sg_type('proxy_entitled') +WHERE NOT EXISTS ( + SELECT 1 + FROM rhnSGTypeBaseAddonCompat + WHERE base_id = lookup_sg_type('salt_entitled') + AND addon_id = lookup_sg_type('proxy_entitled') +); + + +-------------------------------------------------------------------------------- +-- rhnServerServerGroupArchCompat ---------------------------------------------- +-------------------------------------------------------------------------------- +DO $$ +DECLARE + loop_server_arch_id INT; + proxy_sg_type_id INT; +BEGIN + proxy_sg_type_id := lookup_sg_type('proxy_entitled'); + + FOR loop_server_arch_id IN + SELECT id FROM rhnserverarch + LOOP + INSERT INTO rhnServerServerGroupArchCompat (server_arch_id, server_group_type) + SELECT + loop_server_arch_id, + proxy_sg_type_id + WHERE NOT EXISTS ( + SELECT 1 + FROM rhnServerServerGroupArchCompat AS rs + WHERE rs.server_arch_id = loop_server_arch_id AND rs.server_group_type = proxy_sg_type_id + ); + END LOOP; +END $$; \ No newline at end of file diff --git a/schema/spacewalk/upgrade/susemanager-schema-5.1.2-to-susemanager-schema-5.1.3/011-add-proxy_configuration_apply-action-type.sql b/schema/spacewalk/upgrade/susemanager-schema-5.1.2-to-susemanager-schema-5.1.3/011-add-proxy_configuration_apply-action-type.sql new file mode 100644 index 000000000000..3c52ddc5d5fa --- /dev/null +++ b/schema/spacewalk/upgrade/susemanager-schema-5.1.2-to-susemanager-schema-5.1.3/011-add-proxy_configuration_apply-action-type.sql @@ -0,0 +1,3 @@ +insert into rhnActionType + select 525, 'proxy_configuration.apply', 'Apply a proxy configuration to a system', 'N', 'N', 'N' + where not exists(select 1 from rhnActionType where id = 525); \ No newline at end of file diff --git a/susemanager-utils/susemanager-sls/salt/apply_proxy_config.sls b/susemanager-utils/susemanager-sls/salt/apply_proxy_config.sls new file mode 100644 index 000000000000..44395a037bbc --- /dev/null +++ b/susemanager-utils/susemanager-sls/salt/apply_proxy_config.sls @@ -0,0 +1,141 @@ +{%- set mgrpxy_installed = salt['pkg.version']('mgrpxy') %} +{%- set mgrpxy_status_output = salt['cmd.run']('mgrpxy status 2>&1', python_shell=True) %} +{%- set mgrpxy_operation = 'install' if not mgrpxy_installed or 'Error: no installed proxy detected' in mgrpxy_status_output else 'upgrade' %} +{%- set transactional = grains['transactional'] %} + +podman_installed_running: + pkg.installed: + - name: podman + service.running: + - name: podman + - enable: True + +mgrpxy_installed: + pkg.installed: + - name: mgrpxy + - refresh: True + +/etc/uyuni/proxy/config.yaml: + file.managed: + - name: /etc/uyuni/proxy/config.yaml + - user: root + - group: root + - mode: 644 + - makedirs: True + - template: jinja + - contents: | + server: {{ pillar['server'] }} + ca_crt: | + {{ pillar['ca_crt'] | replace('\\n', '\n') | indent(12) }} + proxy_fqdn: {{ pillar['proxy_fqdn'] }} + max_cache_size_mb: {{ pillar['max_cache_size_mb'] }} + server_version: "{{ pillar['server_version'] }}" + email: {{ pillar['email'] }} + +/etc/uyuni/proxy/httpd.yaml: + file.managed: + - name: /etc/uyuni/proxy/httpd.yaml + - user: root + - group: root + - mode: 600 + - makedirs: True + - template: jinja + - contents: | + httpd: + system_id: {{ pillar['httpd']['system_id'] }} + server_crt: | + {{ pillar['httpd']['server_crt'] | replace('\\n', '\n') | indent(12) }} + server_key: | + {{ pillar['httpd']['server_key'] | replace('\\n', '\n') | indent(12) }} + +/etc/uyuni/proxy/ssh.yaml: + file.managed: + - name: /etc/uyuni/proxy/ssh.yaml + - user: root + - group: root + - mode: 600 + - makedirs: True + - template: jinja + - contents: | + ssh: + server_ssh_key_pub: | + {{ pillar['ssh']['server_ssh_key_pub'] | replace('\\n', '\n') | indent(12) }} + server_ssh_push: | + {{ pillar['ssh']['server_ssh_push'] | replace('\\n', '\n') | indent(12) }} + server_ssh_push_pub: | + {{ pillar['ssh']['server_ssh_push_pub'] | replace('\\n', '\n') | indent(12) }} + + +{% if transactional %} + +# If we're on a transactional system, we'll install mgrpxy apply as a service that +# executes the mgrpxy install/update command after next reboot +/etc/systemd/system/apply_mgrpxy.service: + file.managed: + - name: /etc/systemd/system/apply_mgrpxy.service + - user: root + - group: root + - mode: 664 + - makedirs: True + - template: jinja + - contents: | + [Unit] + Description=Install/Update mgrpxy proxy + After=network-online.target podman.service + Requires=network-online.target podman.service + + [Service] + Type=oneshot + ExecStart=/bin/bash -c 'mgrpxy {{ mgrpxy_operation }} podman --logLevel debug \ + {% if pillar['httpd_image'] is defined and pillar['httpd_tag'] is defined %}--httpd-image {{ pillar['httpd_image'] }} --httpd-tag {{ pillar['httpd_tag'] }} {% endif %} \ + {% if pillar['saltbroker_image'] is defined and pillar['saltbroker_tag'] is defined %}--saltbroker-image {{ pillar['saltbroker_image'] }} --saltbroker-tag {{ pillar['saltbroker_tag'] }} {% endif %} \ + {% if pillar['squid_image'] is defined and pillar['squid_tag'] is defined %}--squid-image {{ pillar['squid_image'] }} --squid-tag {{ pillar['squid_tag'] }} {% endif %} \ + {% if pillar['ssh_image'] is defined and pillar['ssh_tag'] is defined %}--ssh-image {{ pillar['ssh_image'] }} --ssh-tag {{ pillar['ssh_tag'] }} {% endif %} \ + {% if pillar['tftpd_image'] is defined and pillar['tftpd_tag'] is defined %}--tftpd-image {{ pillar['tftpd_image'] }} --tftpd-tag {{ pillar['tftpd_tag'] }} {% endif %} \ + 2>&1 | tee -a /var/log/mgrpxy_install.log' + + ExecStartPost=/bin/bash -c 'STATUS_OUTPUT=$(mgrpxy status 2>&1); \ + echo "$STATUS_OUTPUT" | tee -a /var/log/mgrpxy_install.log; \ + if ! echo "$STATUS_OUTPUT" | grep -q "Error: no installed proxy detected"; then \ + echo "mgrpxy was successfully {{ mgrpxy_operation }}ed. Removing apply mgrpxy service and configuration file." | tee -a /var/log/mgrpxy_install.log; \ + rm -f /etc/systemd/system/apply_mgrpxy.service; \ + else \ + echo "mgrpxy status check failed. Service file will remain for troubleshooting." | tee -a /var/log/mgrpxy_install.log; \ + fi' + + [Install] + WantedBy=multi-user.target + +# The system will run this service to enable apply_mgrpxy.service after reboot +enable_apply_mgrpxy_service: + service.running: + - name: apply_mgrpxy.service + - enable: True + - require: + - file: /etc/systemd/system/apply_mgrpxy.service + - file: /etc/uyuni/proxy/config.yaml + - file: /etc/uyuni/proxy/httpd.yaml + - file: /etc/uyuni/proxy/ssh.yaml + + +{% else %} + +apply_proxy_configuration: + cmd.run: + - name: > + mgrpxy {{ mgrpxy_operation }} podman --logLevel debug \ + {% if pillar['httpd_image'] is defined and pillar['httpd_tag'] is defined %} --httpd-image {{ pillar['httpd_image'] }} --httpd-tag {{ pillar['httpd_tag'] }} {% endif %} \ + {% if pillar['saltbroker_image'] is defined and pillar['saltbroker_tag'] is defined %} --saltbroker-image {{ pillar['saltbroker_image'] }} --saltbroker-tag {{ pillar['saltbroker_tag'] }} {% endif %} \ + {% if pillar['squid_image'] is defined and pillar['squid_tag'] is defined %} --squid-image {{ pillar['squid_image'] }} --squid-tag {{ pillar['squid_tag'] }} {% endif %} \ + {% if pillar['ssh_image'] is defined and pillar['ssh_tag'] is defined %} --ssh-image {{ pillar['ssh_image'] }} --ssh-tag {{ pillar['ssh_tag'] }} {% endif %} \ + {% if pillar['tftpd_image'] is defined and pillar['tftpd_tag'] is defined %} --tftpd-image {{ pillar['tftpd_image'] }} --tftpd-tag {{ pillar['tftpd_tag'] }} {% endif %} \ + > 2>&1 | tee -a /var/log/mgrpxy_install.log + - shell: /bin/bash + - require: + - file: /etc/uyuni/proxy/config.yaml + - file: /etc/uyuni/proxy/httpd.yaml + - file: /etc/uyuni/proxy/ssh.yaml + - service: podman_installed_running + - pkg: mgrpxy_installed + +{%- endif %} diff --git a/susemanager-utils/susemanager-sls/salt/install_mgrpxy.service b/susemanager-utils/susemanager-sls/salt/install_mgrpxy.service new file mode 100644 index 000000000000..8d813c4c9e6e --- /dev/null +++ b/susemanager-utils/susemanager-sls/salt/install_mgrpxy.service @@ -0,0 +1,22 @@ +[Unit] +Description=Install mgrpxy proxy +After=network-online.target podman.service +Requires=network-online.target podman.service + +[Service] +Type=oneshot +ExecStart=/bin/bash -c '. + install podman /etc/salt/proxy.tar.gz --logLevel debug | tee /var/log/mgrpxy_install.log' + +ExecStartPost=/bin/bash -c 'STATUS_OUTPUT=$(mgrpxy status 2>&1); \ + echo "$STATUS_OUTPUT" | tee -a /var/log/mgrpxy_install.log; \ + if ! echo "$STATUS_OUTPUT" | grep -q "Error: no installed proxy detected"; then \ + echo "mgrpxy is running successfully. Removing install mgrpxy service and configuration file." | tee -a /var/log/mgrpxy_install.log; \ + rm -f /etc/systemd/system/install_mgrpxy.service; \ + rm -f /etc/salt/proxy.tar.gz; \ + else \ + echo "mgrpxy status check failed. Service file will remain for troubleshooting." | tee -a /var/log/mgrpxy_install.log; \ + fi' + +[Install] +WantedBy=multi-user.target diff --git a/susemanager-utils/susemanager-sls/susemanager-sls.changes.rjpmestre.simplified-proxy-onboarding b/susemanager-utils/susemanager-sls/susemanager-sls.changes.rjpmestre.simplified-proxy-onboarding new file mode 100644 index 000000000000..648110a2b22b --- /dev/null +++ b/susemanager-utils/susemanager-sls/susemanager-sls.changes.rjpmestre.simplified-proxy-onboarding @@ -0,0 +1 @@ +- Add proxy onboarding feature diff --git a/web/html/src/components/buttons.tsx b/web/html/src/components/buttons.tsx index dfafd95e077e..8c8a0cc7a806 100644 --- a/web/html/src/components/buttons.tsx +++ b/web/html/src/components/buttons.tsx @@ -66,6 +66,12 @@ type AsyncProps = BaseProps & { * Initial state of the button ('failure', 'warning' or 'initial') */ initialValue?: string; + + /** + * HTML type attribute for the button ('button', 'submit', 'reset'). + * Defaults to 'button'. + */ + type?: "button" | "submit" | "reset"; }; type AsyncState = { @@ -136,6 +142,7 @@ export class AsyncButton extends _ButtonBase { className={style} disabled={this.state.value === "waiting" || this.props.disabled} onClick={this.trigger} + type={this.props.type ?? "button"} > {this.state.value === "waiting" ? ( diff --git a/web/html/src/components/icontag.tsx b/web/html/src/components/icontag.tsx index 5fb8d4caac80..992f12b39f43 100644 --- a/web/html/src/components/icontag.tsx +++ b/web/html/src/components/icontag.tsx @@ -91,6 +91,7 @@ function IconTag(props: Props) { "item-enabled": "fa fa-check text-success", "item-enabled-pending": "fa fa-hand-o-right text-success", "item-import": "fa fa-level-down", + "item-proxy-convert": "fa fa-arrow-up", "item-search": "fa fa-eye", "item-ssm-add": "fa fa-plus-circle", "item-ssm-del": "fa fa-minus-circle", diff --git a/web/html/src/manager/minion/index.ts b/web/html/src/manager/minion/index.ts index c46f77acd6cf..e592beec4b27 100644 --- a/web/html/src/manager/minion/index.ts +++ b/web/html/src/manager/minion/index.ts @@ -10,4 +10,5 @@ export default { "minion/ptf/ptf-install": () => import("./ptf/ptf-install.renderer"), "minion/coco/coco-settings": () => import("./coco/coco-settings.renderer"), "minion/coco/coco-scans-list": () => import("./coco/coco-scans-list.renderer"), + "minion/proxy/proxy-config": () => import("./proxy/proxy-config.renderer"), }; diff --git a/web/html/src/manager/minion/proxy/proxy-config-messages.tsx b/web/html/src/manager/minion/proxy/proxy-config-messages.tsx new file mode 100644 index 000000000000..f5d888d78de0 --- /dev/null +++ b/web/html/src/manager/minion/proxy/proxy-config-messages.tsx @@ -0,0 +1,40 @@ +import * as React from "react"; + +import { Messages } from "components/messages/messages"; + +type SuccessType = boolean | undefined; + +export const ContainerConfigMessages = (success: SuccessType, messagesIn: React.ReactNode[], loading: boolean) => { + if (success) { + return ( + {t("Proxy configuration successfully applied.")}

      , + }, + ]} + /> + ); + } else if (messagesIn.length > 0) { + return ( + + ); + } else if (loading) { + return ( + {t("Applying proxy configuration: waiting for a response...")}

      , + }, + ]} + /> + ); + } + return null; +}; diff --git a/web/html/src/manager/minion/proxy/proxy-config.renderer.tsx b/web/html/src/manager/minion/proxy/proxy-config.renderer.tsx new file mode 100644 index 000000000000..8876bd6538a3 --- /dev/null +++ b/web/html/src/manager/minion/proxy/proxy-config.renderer.tsx @@ -0,0 +1,25 @@ +import SpaRenderer from "core/spa/spa-renderer"; + +import { ProxyConfig } from "./proxy-config"; + +export const renderer = ( + id: string, + { + serverId, + isUyuni, + parents, + currentConfig, + initFailMessage, + }: { serverId: string; isUyuni: boolean; parents: any[]; currentConfig: any; initFailMessage: string } +) => { + return SpaRenderer.renderNavigationReact( + , + document.getElementById(id) + ); +}; diff --git a/web/html/src/manager/minion/proxy/proxy-config.tsx b/web/html/src/manager/minion/proxy/proxy-config.tsx new file mode 100644 index 000000000000..a70491ea82d3 --- /dev/null +++ b/web/html/src/manager/minion/proxy/proxy-config.tsx @@ -0,0 +1,755 @@ +import * as React from "react"; +import { useCallback, useEffect, useState } from "react"; + +import { debounce } from "lodash"; + +import { AsyncButton, SubmitButton } from "components/buttons"; +import { Select } from "components/input"; +import { Form } from "components/input/form/Form"; +import { FormMultiInput } from "components/input/form-multi-input/FormMultiInput"; +import { unflattenModel } from "components/input/form-utils"; +import { Radio } from "components/input/radio/Radio"; +import { Text } from "components/input/text/Text"; +import { Panel } from "components/panels/Panel"; +import { TopPanel } from "components/panels/TopPanel"; +import Validation from "components/validation"; + +import Network from "utils/network"; + +import { ContainerConfigMessages } from "./proxy-config-messages"; + +// See java/code/src/com/suse/manager/webui/templates/proxy/proxy-config.jade +enum UseCertsMode { + Replace = "replace", + Keep = "keep", +} + +enum SourceMode { + Registry = "registry", + RPM = "rpm", +} + +enum RegistryMode { + Simple = "simple", + Advanced = "advanced", +} + +type ProxyConfigModel = { + rootCA: string; + rootCA_safe?: string; + proxyCertificate: string; + proxyCertificate_safe?: string; + proxyKey: string; + proxyKey_safe?: string; + intermediateCAs?: string[]; + intermediateCAs_safe?: string[]; + proxyAdminEmail: string; + maxSquidCacheSize: string; + parentFQDN: string; + proxyPort: string; + useCertsMode: UseCertsMode; + sourceMode: SourceMode; + registryMode: RegistryMode; + registryBaseURL: string; + registryBaseTag: string; + registryHttpdURL: string; + registryHttpdTag: string; + registrySaltbrokerURL: string; + registrySaltbrokerTag: string; + registrySquidURL: string; + registrySquidTag: string; + registrySshURL: string; + registrySshTag: string; + registryTftpdURL: string; + registryTftpdTag: string; +}; + +const modelDefaults = { + rootCA: "", + proxyCertificate: "", + proxyKey: "", + proxyAdminEmail: "", + maxSquidCacheSize: "", + parentFQDN: "", + proxyPort: "8022", + useCertsMode: UseCertsMode.Replace, + sourceMode: SourceMode.Registry, + registryMode: RegistryMode.Simple, + registryBaseURL: "", + registryBaseTag: "", + registryHttpdURL: "", + registryHttpdTag: "", + registrySaltbrokerURL: "", + registrySaltbrokerTag: "", + registrySquidURL: "", + registrySquidTag: "", + registrySshURL: "", + registrySshTag: "", + registryTftpdURL: "", + registryTftpdTag: "", +}; + +interface Parent { + id: number; + name: string; + selected: boolean; + disabled: boolean; +} + +interface ProxyConfigProps { + serverId: string; + isUyuni: boolean; + parents: Parent[]; + currentConfig: ProxyConfigModel; + initFailMessage?: string; +} + +type TagOptions = { + registryBaseURL?: string[]; + registryHttpdURL?: string[]; + registrySaltbrokerURL?: string[]; + registrySquidURL?: string[]; + registrySshURL?: string[]; + registryTftpdURL?: string[]; +}; + +const imageNames = [ + "registryHttpdURL", + "registrySaltbrokerURL", + "registrySquidURL", + "registrySshURL", + "registryTftpdURL", +]; + +export function ProxyConfig({ serverId, isUyuni, parents, currentConfig, initFailMessage }: ProxyConfigProps) { + const [messages, setMessages] = useState([]); + const [loading, setLoading] = useState(false); + const [success, setSuccess] = useState(); + const [isValidated, setIsValidated] = useState(false); + const [errors, setErrors] = useState({}); + const [tagOptions, setTagOptions] = useState({}); + + const showUseCertsMode = + currentConfig.rootCA_safe || currentConfig.proxyKey_safe || currentConfig.proxyCertificate_safe; + const originalConfig = { ...currentConfig }; + + const [model, setModel] = useState(() => { + const initialModel = { + ...modelDefaults, + ...currentConfig, + }; + + if (showUseCertsMode) { + return { + ...initialModel, + useCertsMode: UseCertsMode.Keep, + }; + } + + return initialModel; + }); + + useEffect(() => { + imageNames.forEach((url) => { + if (currentConfig[url]) { + retrieveRegistryTags(currentConfig, url); + } + }); + if (initFailMessage) { + setSuccess(false); + setMessages([initFailMessage]); + } + }, [currentConfig]); + + const registryUrlExample = isUyuni ? "registry.opensuse.org/.../uyuni" : "registry.suse.com/suse/manager/..."; + + const onSubmit = () => { + setMessages([]); + setLoading(true); + + const fileFields = ["rootCA", "intermediateCAs", "proxyCertificate", "proxyKey"]; + + const fileReaders = Object.keys(model) + .filter((key) => { + const matcher = key.match(/^([a-zA-Z0-9]*[A-Za-z])[0-9]*$/); + const fieldName = matcher ? matcher[1] : key; + return fileFields.includes(fieldName); + }) + .map((fieldName) => { + const field = document.getElementById(fieldName) as HTMLInputElement; + if (field?.files?.[0]) { + const file = field.files[0]; + return new Promise((resolve) => { + const reader = new FileReader(); + reader.onload = (e) => { + if (e.target?.result instanceof ArrayBuffer) { + // Should never happen since we call readAsText, just quiets tsc + resolve(undefined); + } else { + resolve({ [fieldName]: e.target?.result }); + } + }; + reader.readAsText(file); + }); + } + return undefined; + }) + .filter((promise) => promise !== undefined); + + Promise.all(fileReaders).then((values) => { + const commonData = { + serverId: serverId, + proxyPort: model.proxyPort ? parseInt(model.proxyPort, 10) : 8022, + parentFQDN: model.parentFQDN, + maxSquidCacheSize: parseInt(model.maxSquidCacheSize, 10), + proxyAdminEmail: model.proxyAdminEmail, + sourceMode: model.sourceMode, + registryMode: model.registryMode, + useCertsMode: model.useCertsMode, + }; + const registryData = + model.sourceMode === SourceMode.Registry + ? Object.assign( + {}, + model.registryMode === RegistryMode.Simple + ? { + registryBaseURL: model.registryBaseURL, + registryBaseTag: model.registryBaseTag, + } + : { + registryHttpdURL: model.registryHttpdURL, + registryHttpdTag: model.registryHttpdTag, + registrySaltbrokerURL: model.registrySaltbrokerURL, + registrySaltbrokerTag: model.registrySaltbrokerTag, + registrySquidURL: model.registrySquidURL, + registrySquidTag: model.registrySquidTag, + registrySshURL: model.registrySshURL, + registrySshTag: model.registrySshTag, + registryTftpdURL: model.registryTftpdURL, + registryTftpdTag: model.registryTftpdTag, + } + ) + : {}; + + const formData = unflattenModel(Object.assign({}, commonData, registryData, ...values)); + Network.post("/rhn/manager/systems/details/proxy-config", formData).then( + (data) => { + setSuccess(data.success); + setMessages([]); + setLoading(false); + }, + (xhr) => { + try { + setSuccess(false); + setMessages(JSON.parse(xhr.responseText).messages); + setLoading(false); + } catch (err) { + const errMessages = + xhr.status === 0 + ? t("Request interrupted or invalid response received from the server.") + : Network.errorMessageByStatus(xhr.status)[0]; + setSuccess(false); + setMessages([errMessages]); + setLoading(false); + } + } + ); + }); + }; + + const clearFields = () => { + setModel(modelDefaults); + }; + + const onValidate = (isValidated: boolean) => { + setIsValidated(isValidated); + }; + + const onChange = (newModel) => { + setModel(Object.assign({}, newModel)); + asyncValidate(newModel); + }; + + const onAddField = (fieldName: string) => { + return (index: number) => setModel(Object.assign({}, model, { [fieldName + index]: "" })); + }; + + const onRemoveField = (fieldName: string) => { + return (index: number) => { + const newModel = { ...model }; + delete newModel[`${fieldName}${index}`]; + setModel(newModel); + }; + }; + + /** + * Restore registry inputs + */ + const restoreRegistryInputs = () => { + setModel({ + ...model, + registryMode: RegistryMode.Advanced, + registryHttpdURL: originalConfig.registryHttpdURL, + registryHttpdTag: originalConfig.registryHttpdTag, + registrySaltbrokerURL: originalConfig.registrySaltbrokerURL, + registrySaltbrokerTag: originalConfig.registrySaltbrokerTag, + registrySquidURL: originalConfig.registrySquidURL, + registrySquidTag: originalConfig.registrySquidTag, + registrySshURL: originalConfig.registrySshURL, + registrySshTag: originalConfig.registrySshTag, + registryTftpdURL: originalConfig.registryTftpdURL, + registryTftpdTag: originalConfig.registryTftpdTag, + }); + }; + + const onChangeSourceMode = (e, v) => { + if (SourceMode.Registry === v && SourceMode.Registry === originalConfig.sourceMode) { + restoreRegistryInputs(); + } + }; + + const onChangeRegistryeMode = (e, v) => { + if (RegistryMode.Advanced === v && Object.keys(originalConfig).length > 0) { + restoreRegistryInputs(); + } + }; + + const getMinionNames = (data: any[] = []) => { + return Array.from(new Set(data.map((item) => item.name))).sort(); + }; + + const useDebounce = (callback: (...args: any) => any, timeoutMs: number) => + useCallback(debounce(callback, timeoutMs), []); + + const asyncValidate = useDebounce(async (newModel: typeof model) => { + setErrors({}); + if (newModel.registryMode === RegistryMode.Simple) { + if (newModel.registryBaseURL && !tagOptions.registryBaseURL?.length) { + retrieveRegistryTags(newModel, "registryBaseURL"); + } + } else if (newModel.registryMode === RegistryMode.Advanced) { + imageNames.forEach((property) => { + if (newModel[property] && !tagOptions[property]?.length) { + retrieveRegistryTags(newModel, property); + } + }); + } + }, 500); + + const retrieveRegistryTags = async (newModel: typeof model, name) => { + const registryUrl = newModel[name]; + if (!registryUrl) { + setErrors((prev) => ({ ...prev, [name]: [] })); + setTagOptions((prev) => ({ ...prev, [name]: [] })); + return; + } + + try { + const response = await Network.post("/rhn/manager/systems/details/proxy-config/registry-url", { + registryUrl: registryUrl, + isExact: name !== "registryBaseURL", + }); + + if (response?.success) { + setErrors((prev) => ({ ...prev, [name]: [] })); + setTagOptions((prev) => ({ + ...prev, + [name]: response.data || [], + })); + } else { + const errorMessage = response?.messages?.join(", ") || "Validation Failed"; + setErrors((prev) => ({ ...prev, [name]: errorMessage })); + setTagOptions((prev) => ({ ...prev, [name]: [] })); + } + } catch (error) { + setErrors((prev) => ({ ...prev, [name]: "Error during validation" })); + setTagOptions((prev) => ({ ...prev, [name]: [] })); + } + }; + + return ( + +

      {t("Convert an already onboarded minion to a proxy or update the configuration of an existing proxy.")}

      + {ContainerConfigMessages(success, messages, loading)} + {!initFailMessage && ( +
      + ({ + value: tag, + label: tag, + })) || [] + } + isClearable={true} + /> + + )} + {model.registryMode === RegistryMode.Advanced && ( + <> + + ({ + value: tag, + label: tag, + })) || [] + } + isClearable={true} + /> + + + ({ + value: tag, + label: tag, + })) || [] + } + isClearable={true} + /> + + +