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 && (
+
+ )}
+
+ );
+}
diff --git a/web/html/src/manager/systems/list-filter.tsx b/web/html/src/manager/systems/list-filter.tsx
index d30e6866dddb..f307a9059ba7 100644
--- a/web/html/src/manager/systems/list-filter.tsx
+++ b/web/html/src/manager/systems/list-filter.tsx
@@ -18,6 +18,7 @@ const SYSTEM_TYPE_OPTIONS = [
{ value: "osimage_build_host", label: t("OS Image Build Host") },
{ value: "salt_entitled", label: t("Salt") },
{ value: "virtualization_host", label: t("Virtualization Host") },
+ { value: "proxy_entitled", label: t("Proxy") },
];
const STATUS_TYPE_OPTIONS = [
diff --git a/web/spacewalk-web.changes.rjpmestre.simplified-proxy-onboarding b/web/spacewalk-web.changes.rjpmestre.simplified-proxy-onboarding
new file mode 100644
index 000000000000..648110a2b22b
--- /dev/null
+++ b/web/spacewalk-web.changes.rjpmestre.simplified-proxy-onboarding
@@ -0,0 +1 @@
+- Add proxy onboarding feature