ee.ria.xroad.proxy.clientproxy.ClientMessageProcessor.java Source code

Java tutorial

Introduction

Here is the source code for ee.ria.xroad.proxy.clientproxy.ClientMessageProcessor.java

Source

/**
 * The MIT License
 * Copyright (c) 2015 Estonian Information System Authority (RIA), Population Register Centre (VRK)
 *
 * Permission is hereby granted, free of charge, to any person obtaining a copy
 * of this software and associated documentation files (the "Software"), to deal
 * in the Software without restriction, including without limitation the rights
 * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
 * copies of the Software, and to permit persons to whom the Software is
 * furnished to do so, subject to the following conditions:
 *
 * The above copyright notice and this permission notice shall be included in
 * all copies or substantial portions of the Software.
 *
 * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
 * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
 * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
 * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
 * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
 * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
 * THE SOFTWARE.
 */
package ee.ria.xroad.proxy.clientproxy;

import ee.ria.xroad.common.CodedException;
import ee.ria.xroad.common.SystemProperties;
import ee.ria.xroad.common.cert.CertChain;
import ee.ria.xroad.common.conf.globalconf.GlobalConf;
import ee.ria.xroad.common.conf.serverconf.IsAuthentication;
import ee.ria.xroad.common.conf.serverconf.IsAuthenticationData;
import ee.ria.xroad.common.conf.serverconf.ServerConf;
import ee.ria.xroad.common.conf.serverconf.model.ClientType;
import ee.ria.xroad.common.identifier.CentralServiceId;
import ee.ria.xroad.common.identifier.ClientId;
import ee.ria.xroad.common.identifier.SecurityServerId;
import ee.ria.xroad.common.identifier.ServiceId;
import ee.ria.xroad.common.message.RequestHash;
import ee.ria.xroad.common.message.SaxSoapParserImpl;
import ee.ria.xroad.common.message.SoapFault;
import ee.ria.xroad.common.message.SoapHeader;
import ee.ria.xroad.common.message.SoapMessage;
import ee.ria.xroad.common.message.SoapMessageDecoder;
import ee.ria.xroad.common.message.SoapMessageImpl;
import ee.ria.xroad.common.message.SoapUtils;
import ee.ria.xroad.common.monitoring.MessageInfo;
import ee.ria.xroad.common.monitoring.MessageInfo.Origin;
import ee.ria.xroad.common.monitoring.MonitorAgent;
import ee.ria.xroad.common.opmonitoring.OpMonitoringData;
import ee.ria.xroad.common.util.HttpSender;
import ee.ria.xroad.common.util.MimeUtils;
import ee.ria.xroad.proxy.ProxyMain;
import ee.ria.xroad.proxy.conf.KeyConf;
import ee.ria.xroad.proxy.messagelog.MessageLog;
import ee.ria.xroad.proxy.protocol.ProxyMessage;
import ee.ria.xroad.proxy.protocol.ProxyMessageDecoder;
import ee.ria.xroad.proxy.protocol.ProxyMessageEncoder;
import ee.ria.xroad.proxy.util.MessageProcessorBase;
import lombok.EqualsAndHashCode;
import lombok.SneakyThrows;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.io.IOUtils;
import org.apache.http.client.HttpClient;
import org.apache.http.client.protocol.HttpClientContext;
import org.bouncycastle.cert.ocsp.OCSPResp;
import org.bouncycastle.util.Arrays;
import org.xml.sax.Attributes;
import org.xml.sax.helpers.AttributesImpl;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.xml.namespace.QName;
import java.io.InputStream;
import java.io.PipedInputStream;
import java.io.PipedOutputStream;
import java.io.Writer;
import java.net.URI;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.ThreadFactory;
import java.util.concurrent.TimeUnit;

import static ee.ria.xroad.common.ErrorCodes.*;
import static ee.ria.xroad.common.SystemProperties.getServerProxyPort;
import static ee.ria.xroad.common.SystemProperties.isSslEnabled;
import static ee.ria.xroad.common.util.AbstractHttpSender.CHUNKED_LENGTH;
import static ee.ria.xroad.common.util.CryptoUtils.decodeBase64;
import static ee.ria.xroad.common.util.CryptoUtils.encodeBase64;
import static ee.ria.xroad.common.util.MimeUtils.HEADER_HASH_ALGO_ID;
import static ee.ria.xroad.common.util.MimeUtils.HEADER_ORIGINAL_CONTENT_TYPE;
import static ee.ria.xroad.common.util.MimeUtils.HEADER_ORIGINAL_SOAP_ACTION;
import static ee.ria.xroad.common.util.MimeUtils.HEADER_PROXY_VERSION;
import static ee.ria.xroad.common.util.TimeUtils.getEpochMillisecond;
import static ee.ria.xroad.proxy.clientproxy.FastestConnectionSelectingSSLSocketFactory.ID_TARGETS;

@Slf4j
class ClientMessageProcessor extends MessageProcessorBase {

    /**
     * Timeout for waiting for the SOAP message to be read from the request.
     */
    private static final int WAIT_FOR_SOAP_TIMEOUT = 30; // seconds

    /**
     * By using a count down latch we can make the main thread wait for the
     * request handler thread to read the SOAP request, since we cannot open
     * connection to server proxy before we haven't read the receiver name from
     * request SOAP.
     */
    private final CountDownLatch requestHandlerGate = new CountDownLatch(1);

    /**
     * By using a count down latch we can make the main thread wait for the
     * HTTP sender to finish sending the entire request to the piped output
     * stream, so we can check for errors in the handler thread before
     * receiving the response.
     */
    private final CountDownLatch httpSenderGate = new CountDownLatch(1);

    /**
     * Holds the client side SSL certificate.
     */
    private final IsAuthenticationData clientCert;

    /** Holds the incoming request SOAP message. */
    private volatile String originalSoapAction;
    private volatile SoapMessageImpl requestSoap;
    private volatile ServiceId requestServiceId;

    /** If the request failed, will contain SOAP fault. */
    private volatile CodedException executionException;

    /** Holds the proxy message output stream and associated info. */
    private PipedInputStream reqIns;
    private volatile PipedOutputStream reqOuts;
    private volatile String outputContentType;

    /** Holds the request to the server proxy. */
    private ProxyMessageEncoder request;

    /** Holds the response from server proxy. */
    private ProxyMessage response;

    //** Holds operational monitoring data. */
    private volatile OpMonitoringData opMonitoringData;

    private static final ExecutorService SOAP_HANDLER_EXECUTOR = createSoapHandlerExecutor();

    private static ExecutorService createSoapHandlerExecutor() {
        return Executors.newCachedThreadPool(new ThreadFactory() {
            @Override
            public Thread newThread(Runnable r) {
                Thread handlerThread = new Thread(r);
                handlerThread.setName(Thread.currentThread().getName() + "-soap");

                return handlerThread;
            }
        });
    }

    ClientMessageProcessor(HttpServletRequest servletRequest, HttpServletResponse servletResponse,
            HttpClient httpClient, IsAuthenticationData clientCert, OpMonitoringData opMonitoringData)
            throws Exception {
        super(servletRequest, servletResponse, httpClient);

        this.clientCert = clientCert;
        this.opMonitoringData = opMonitoringData;
        this.reqIns = new PipedInputStream();
        this.reqOuts = new PipedOutputStream(reqIns);
    }

    @Override
    public void process() throws Exception {
        log.trace("process()");

        updateOpMonitoringClientSecurityServerAddress();

        Future<?> soapHandler = SOAP_HANDLER_EXECUTOR.submit(this::handleSoap);

        try {
            // Wait for the request SOAP message to be parsed before we can
            // start sending stuff.
            waitForSoapMessage();

            // If the handler thread excepted, do not continue.
            checkError();

            // Verify that the client is registered
            verifyClientStatus();

            // Check client authentication mode
            verifyClientAuthentication();

            processRequest();

            if (response != null) {
                sendResponse();
            }
        } catch (Exception e) {
            if (reqIns != null) {
                reqIns.close();
            }

            // Let's interrupt the handler thread so that it won't
            // block forever waiting for us to do something.
            soapHandler.cancel(true);

            throw e;
        } finally {
            if (response != null) {
                response.consume();
            }
        }
    }

    private void updateOpMonitoringClientSecurityServerAddress() {
        try {
            opMonitoringData.setClientSecurityServerAddress(getSecurityServerAddress());
        } catch (Exception e) {
            log.error("Failed to assign operational monitoring data field {}",
                    OpMonitoringData.CLIENT_SECURITY_SERVER_ADDRESS, e);
        }
    }

    private void processRequest() throws Exception {
        log.trace("processRequest()");

        try (HttpSender httpSender = createHttpSender()) {
            sendRequest(httpSender);

            // Check for any errors from the handler thread once more.
            waitForRequestSent();
            checkError();

            parseResponse(httpSender);
        }

        checkConsistency();

        logResponseMessage();
    }

    private void sendRequest(HttpSender httpSender) throws Exception {
        log.trace("sendRequest()");

        try {
            // If we're using SSL, we need to include the provider name in
            // the HTTP request so that server proxy could verify the SSL
            // certificate properly.
            if (isSslEnabled()) {
                httpSender.setAttribute(AuthTrustVerifier.ID_PROVIDERNAME, requestServiceId);
            }

            // Start sending the request to server proxies. The underlying
            // SSLConnectionSocketFactory will select the fastest address
            // (socket that connects first) from the provided addresses.
            // Dummy service address is only needed so that host name resolving
            // could do its thing and start the ssl connection.
            URI[] addresses = getServiceAddresses(requestServiceId, requestSoap.getSecurityServer());

            updateOpMonitoringServiceSecurityServerAddress(addresses, httpSender);

            httpSender.setAttribute(ID_TARGETS, addresses);

            if (SystemProperties.isEnableClientProxyPooledConnectionReuse()) {
                // set the servers with this subsystem as the user token, this will pool the connections per groups of
                // security servers.
                httpSender.setAttribute(HttpClientContext.USER_TOKEN, new TargetHostsUserToken(addresses));
            }

            httpSender.setConnectionTimeout(SystemProperties.getClientProxyTimeout());
            httpSender.setSocketTimeout(SystemProperties.getClientProxyHttpClientTimeout());

            httpSender.addHeader(HEADER_HASH_ALGO_ID, SoapUtils.getHashAlgoId());
            httpSender.addHeader(HEADER_PROXY_VERSION, ProxyMain.getVersion());

            // Preserve the original content type in the "x-original-content-type"
            // HTTP header, which will be used to send the request to the
            // service provider
            httpSender.addHeader(HEADER_ORIGINAL_CONTENT_TYPE, servletRequest.getContentType());

            // Preserve the original SOAPAction header
            httpSender.addHeader(HEADER_ORIGINAL_SOAP_ACTION, originalSoapAction);

            try {
                opMonitoringData.setRequestOutTs(getEpochMillisecond());

                httpSender.doPost(getDummyServiceAddress(addresses), reqIns, CHUNKED_LENGTH, outputContentType);

                opMonitoringData.setResponseInTs(getEpochMillisecond());
            } catch (Exception e) {
                // Failed to connect to server proxy
                MonitorAgent.serverProxyFailed(createRequestMessageInfo());

                // Rethrow
                throw e;
            }
        } finally {
            if (reqIns != null) {
                reqIns.close();
            }
        }
    }

    @EqualsAndHashCode
    public static class TargetHostsUserToken {
        private final Set<URI> targetHosts;

        TargetHostsUserToken(Set<URI> targetHosts) {
            if (targetHosts != null) {
                this.targetHosts = targetHosts;
            } else {
                this.targetHosts = new HashSet<>();
            }
        }

        TargetHostsUserToken(URI[] uris) {
            if (uris == null || uris.length == 0) {
                this.targetHosts = Collections.emptySet();
            } else {
                if (uris.length == 1) {
                    this.targetHosts = Collections.singleton(uris[0]);
                } else {
                    this.targetHosts = new HashSet<>(java.util.Arrays.asList(uris));
                }
            }
        }
    }

    private void parseResponse(HttpSender httpSender) throws Exception {
        log.trace("parseResponse()");

        response = new ProxyMessage(httpSender.getResponseHeaders().get(HEADER_ORIGINAL_CONTENT_TYPE));

        ProxyMessageDecoder decoder = new ProxyMessageDecoder(response, httpSender.getResponseContentType(),
                getHashAlgoId(httpSender));
        try {
            decoder.parse(httpSender.getResponseContent());
        } catch (CodedException ex) {
            throw ex.withPrefix(X_SERVICE_FAILED_X);
        }

        updateOpMonitoringDataByResponse(decoder);

        // Ensure we have the required parts.
        checkResponse();

        decoder.verify(requestServiceId.getClientId(), response.getSignature());
    }

    private void updateOpMonitoringServiceSecurityServerAddress(URI addresses[], HttpSender httpSender) {
        if (addresses.length == 1) {
            opMonitoringData.setServiceSecurityServerAddress(addresses[0].getHost());
        } else {
            // In case multiple addresses the service security server
            // address will be founded by received TLS authentication
            // certificate in AuthTrustVerifier class.

            httpSender.setAttribute(OpMonitoringData.class.getName(), opMonitoringData);
        }
    }

    private void updateOpMonitoringDataByResponse(ProxyMessageDecoder decoder) {
        if (response.getSoap() != null) {
            long responseSoapSize = response.getSoap().getBytes().length;

            opMonitoringData.setResponseSoapSize(responseSoapSize);
            opMonitoringData.setResponseAttachmentCount(decoder.getAttachmentCount());

            if (decoder.getAttachmentCount() > 0) {
                opMonitoringData.setResponseMimeSize(responseSoapSize + decoder.getAttachmentsByteCount());
            }
        }
    }

    private void checkResponse() throws Exception {
        log.trace("checkResponse()");

        if (response.getFault() != null) {
            throw response.getFault().toCodedException();
        }

        if (response.getSoap() == null) {
            throw new CodedException(X_MISSING_SOAP, "Response does not have SOAP message");
        }

        if (response.getSignature() == null) {
            throw new CodedException(X_MISSING_SIGNATURE, "Response does not have signature");
        }
    }

    private void checkConsistency() throws Exception {
        log.trace("checkConsistency()");

        try {
            SoapUtils.checkConsistency(requestSoap, response.getSoap());
        } catch (CodedException e) {
            log.error("Inconsistent request-response", e);

            // The error code includes ServiceFailed because it indicates
            // faulty response from service (problem on the other side).
            throw new CodedException(X_INCONSISTENT_RESPONSE,
                    "Response from server proxy is not consistent with request").withPrefix(X_SERVICE_FAILED_X);
        }

        checkRequestHash();
    }

    private void checkRequestHash() throws Exception {
        RequestHash requestHashFromResponse = response.getSoap().getHeader().getRequestHash();

        if (requestHashFromResponse != null) {
            byte[] requestHash = requestSoap.getHash();

            if (log.isTraceEnabled()) {
                log.trace("Calculated request message hash: {}\nRequest message (base64): {}",
                        encodeBase64(requestHash), encodeBase64(requestSoap.getBytes()));
            }

            if (!Arrays.areEqual(requestHash, decodeBase64(requestHashFromResponse.getHash()))) {
                throw new CodedException(X_INCONSISTENT_RESPONSE,
                        "Request message hash does not match request message");
            }
        } else {
            throw new CodedException(X_INCONSISTENT_RESPONSE,
                    "Response from server proxy is missing request message hash");
        }
    }

    private void logResponseMessage() throws Exception {
        log.trace("logResponseMessage()");

        MessageLog.log(response.getSoap(), response.getSignature(), true);
    }

    private void sendResponse() throws Exception {
        log.trace("sendResponse()");

        servletResponse.setStatus(HttpServletResponse.SC_OK);
        servletResponse.setCharacterEncoding(MimeUtils.UTF8);
        servletResponse.setContentType(response.getSoapContentType());

        try (InputStream is = response.getSoapContent()) {
            IOUtils.copy(is, servletResponse.getOutputStream());
        }
    }

    private void waitForSoapMessage() {
        log.trace("waitForSoapMessage()");

        try {
            if (!requestHandlerGate.await(WAIT_FOR_SOAP_TIMEOUT, TimeUnit.SECONDS)) {
                throw new CodedException(X_INTERNAL_ERROR, "Reading SOAP from request timed out");
            }
        } catch (InterruptedException e) {
            log.error("waitForSoapMessage interrupted", e);

            Thread.currentThread().interrupt();
        }
    }

    private void waitForRequestSent() {
        log.trace("waitForRequestSent()");

        try {
            httpSenderGate.await();
        } catch (InterruptedException e) {
            log.error("waitForRequestSent interrupted", e);

            Thread.currentThread().interrupt();
        }
    }

    private void continueProcessing() {
        log.trace("continueProcessing()");

        requestHandlerGate.countDown();
    }

    private void continueReadingResponse() {
        log.trace("continueReadingResponse()");

        httpSenderGate.countDown();
    }

    private void checkError() throws Exception {
        if (executionException != null) {
            log.trace("checkError(): ", executionException);

            throw executionException;
        }
    }

    private void setError(Throwable ex) {
        log.trace("setError()");

        if (executionException == null) {
            executionException = translateException(ex);
        }
    }

    @Override
    public MessageInfo createRequestMessageInfo() {
        if (requestSoap == null) {
            return null;
        }

        return new MessageInfo(Origin.CLIENT_PROXY, requestSoap.getClient(), requestServiceId,
                requestSoap.getUserId(), requestSoap.getQueryId());
    }

    protected void verifyClientStatus() throws Exception {
        ClientId client = requestSoap.getClient();
        String status = ServerConf.getMemberStatus(client);

        if (!ClientType.STATUS_REGISTERED.equals(status)) {
            throw new CodedException(X_UNKNOWN_MEMBER, "Client '%s' not found", client);
        }
    }

    protected void verifyClientAuthentication() throws Exception {
        if (!SystemProperties.shouldVerifyClientCert()) {
            return;
        }

        log.trace("verifyClientAuthentication()");

        ClientId sender = requestSoap.getClient();
        IsAuthentication.verifyClientAuthentication(sender, clientCert);
    }

    private static URI getDummyServiceAddress(URI[] addresses) throws Exception {
        if (!isSslEnabled()) {
            // In non-ssl mode we just connect to the first address
            return addresses[0];
        }

        return new URI("https", null, "localhost", getServerProxyPort(), "/", null, null);
    }

    private static URI[] getServiceAddresses(ServiceId serviceProvider, SecurityServerId serverId)
            throws Exception {
        log.trace("getServiceAddresses({}, {})", serviceProvider, serverId);

        Collection<String> hostNames = GlobalConf.getProviderAddress(serviceProvider.getClientId());

        if (hostNames == null || hostNames.isEmpty()) {
            throw new CodedException(X_UNKNOWN_MEMBER, "Could not find addresses for service provider \"%s\"",
                    serviceProvider);
        }

        if (serverId != null) {
            final String securityServerAddress = GlobalConf.getSecurityServerAddress(serverId);

            if (securityServerAddress == null) {
                throw new CodedException(X_INVALID_SECURITY_SERVER, "Could not find security server \"%s\"",
                        serverId);
            }

            if (!hostNames.contains(securityServerAddress)) {
                throw new CodedException(X_INVALID_SECURITY_SERVER, "Invalid security server \"%s\"",
                        serviceProvider);
            }

            hostNames = Collections.singleton(securityServerAddress);
        }

        String protocol = isSslEnabled() ? "https" : "http";
        int port = getServerProxyPort();

        List<URI> addresses = new ArrayList<>(hostNames.size());

        for (String host : hostNames) {
            addresses.add(new URI(protocol, null, host, port, "/", null, null));
        }

        return addresses.toArray(new URI[addresses.size()]);
    }

    private static String getHashAlgoId(HttpSender httpSender) {
        return httpSender.getResponseHeaders().get(HEADER_HASH_ALGO_ID);
    }

    public void handleSoap() {
        try (SoapMessageHandler handler = new SoapMessageHandler()) {
            SoapMessageDecoder soapMessageDecoder = new SoapMessageDecoder(servletRequest.getContentType(), handler,
                    new RequestSoapParserImpl());
            try {
                originalSoapAction = validateSoapActionHeader(servletRequest.getHeader("SOAPAction"));
                soapMessageDecoder.parse(servletRequest.getInputStream());
            } catch (Exception ex) {
                throw new ClientException(translateException(ex));
            }
        } catch (Throwable ex) {
            setError(ex);
        } finally {
            continueProcessing();
            continueReadingResponse();
        }
    }

    private class SoapMessageHandler implements SoapMessageDecoder.Callback {

        @Override
        public void soap(SoapMessage message, Map<String, String> headers) throws Exception {
            if (log.isTraceEnabled()) {
                log.trace("soap({})", message.getXml());
            }

            requestSoap = (SoapMessageImpl) message;
            requestServiceId = requestSoap.getService();

            updateOpMonitoringDataBySoapMessage(opMonitoringData, requestSoap);

            if (request == null) {
                request = new ProxyMessageEncoder(reqOuts, SoapUtils.getHashAlgoId());
                outputContentType = request.getContentType();
            }

            // We have the request SOAP message, we can start sending the
            // request to server proxy.
            continueProcessing();

            // In SSL mode, we need to send the OCSP response of our SSL cert.
            if (isSslEnabled()) {
                writeOcspResponses();
            }

            request.soap(requestSoap, headers);
        }

        @Override
        public void attachment(String contentType, InputStream content, Map<String, String> additionalHeaders)
                throws Exception {
            log.trace("attachment()");

            request.attachment(contentType, content, additionalHeaders);
        }

        @Override
        public void fault(SoapFault fault) throws Exception {
            onError(fault.toCodedException());
        }

        @Override
        public void onCompleted() {
            log.trace("onCompleted()");

            if (requestSoap == null) {
                setError(new ClientException(X_MISSING_SOAP, "Request does not contain SOAP message"));

                return;
            }

            updateOpMonitoringData();

            try {
                request.sign(KeyConf.getSigningCtx(requestSoap.getClient()));
                logRequestMessage();
                request.writeSignature();
            } catch (Exception ex) {
                setError(ex);
            }
        }

        private void updateOpMonitoringData() {
            opMonitoringData.setRequestAttachmentCount(request.getAttachmentCount());

            if (request.getAttachmentCount() > 0) {
                opMonitoringData
                        .setRequestMimeSize(requestSoap.getBytes().length + request.getAttachmentsByteCount());
            }
        }

        private void logRequestMessage() throws Exception {
            log.trace("logRequestMessage()");

            MessageLog.log(requestSoap, request.getSignature(), true);
        }

        @Override
        public void onError(Exception e) throws Exception {
            log.error("onError()", e);

            // Simply re-throw
            throw e;
        }

        private void writeOcspResponses() throws Exception {
            CertChain chain = KeyConf.getAuthKey().getCertChain();
            // exclude TopCA
            List<OCSPResp> ocspResponses = KeyConf.getAllOcspResponses(chain.getAllCertsWithoutTrustedRoot());

            for (OCSPResp ocsp : ocspResponses) {
                request.ocspResponse(ocsp);
            }
        }

        @Override
        public void close() {
            if (request != null) {
                try {
                    request.close();
                } catch (Exception e) {
                    setError(e);
                }
            }
        }
    }

    /**
     * Soap parser that changes the CentralServiceId to ServiceId in message
     * header.
     */
    private class RequestSoapParserImpl extends SaxSoapParserImpl {

        private ServiceId serviceId;

        private String nestedPrefix;

        private AttributesImpl wrapperElementAttributes;
        private Attributes nestedElementAttributes;

        private char[] nestedTabs;
        private char[] wrapperTabs;

        private boolean inServiceElement;
        private boolean inHeader;

        private SoapHeaderHandler headerHandler;

        // do not write processed XML beyond the header if not a central
        // service request, use raw request XML instead
        @Override
        protected boolean isProcessedXmlRequired() {
            boolean headerNotProcessed = headerHandler == null || !headerHandler.isFinished();

            return headerNotProcessed || headerHandler.getHeader().getCentralService() != null;
        }

        @Override
        protected void writeStartElementXml(String prefix, QName element, Attributes attributes, Writer writer) {
            if (inHeader && element.equals(QNAME_XROAD_CENTRAL_SERVICE)) {
                beginServiceElementSubstitution(attributes);
                inServiceElement = true;
            } else if (!inServiceElement) {
                super.writeStartElementXml(prefix, element, attributes, writer);
            }
        }

        @Override
        protected void writeEndElementXml(String prefix, QName element, Attributes attributes, Writer writer) {
            if (inHeader) {
                if (element.equals(QNAME_XROAD_CENTRAL_SERVICE)) {
                    if (serviceId != null) {
                        finishServiceElementSubstitution(prefix, writer);
                    }

                    inServiceElement = false;
                } else if (!inServiceElement) {
                    super.writeEndElementXml(prefix, element, attributes, writer);
                }

                if (inServiceElement && element.equals(QNAME_ID_SERVICE_CODE)) {
                    nestedPrefix = prefix;
                    nestedElementAttributes = attributes;
                }
            } else {
                super.writeEndElementXml(prefix, element, attributes, writer);
            }
        }

        @Override
        protected void writeCharactersXml(char[] characters, int start, int length, Writer writer) {
            if (inServiceElement) {
                String value = new String(characters, start, length);
                char[] chars = value.toCharArray();

                if (value.trim().isEmpty()) {
                    if (nestedTabs == null) {
                        nestedTabs = chars;
                    }

                    wrapperTabs = chars;
                }
            } else {
                super.writeCharactersXml(characters, start, length, writer);
            }
        }

        @Override
        protected SoapHeaderHandler getSoapHeaderHandler(SoapHeader header) {
            headerHandler = new SoapHeaderHandler(header) {
                @Override
                protected void openTag() {
                    super.openTag();
                    inHeader = true;
                }

                @Override
                protected void onCentralService(CentralServiceId centralServiceId) {
                    super.onCentralService(centralServiceId);
                    header.setCentralService(centralServiceId);
                    serviceId = GlobalConf.getServiceId(centralServiceId);
                    header.setService(serviceId);
                }

                @Override
                protected void closeTag() {
                    super.closeTag();
                    inHeader = false;
                }
            };

            return headerHandler;
        }

        private void beginServiceElementSubstitution(Attributes attributes) {
            wrapperElementAttributes = new AttributesImpl(attributes);

            for (int i = 0; i < wrapperElementAttributes.getLength(); i++) {
                if (wrapperElementAttributes.getValue(i).endsWith("CENTRALSERVICE")) {
                    wrapperElementAttributes.setValue(i, "SERVICE");

                    break;
                }
            }
        }

        private void finishServiceElementSubstitution(String prefix, Writer writer) {
            super.writeStartElementXml(prefix, QNAME_XROAD_SERVICE, wrapperElementAttributes, writer);

            writeElement(writer, QNAME_ID_INSTANCE, serviceId.getXRoadInstance());
            writeElement(writer, QNAME_ID_MEMBER_CLASS, serviceId.getMemberClass());
            writeElement(writer, QNAME_ID_MEMBER_CODE, serviceId.getMemberCode());

            if (serviceId.getSubsystemCode() != null) {
                String subsystemCode = serviceId.getSubsystemCode();
                writeElement(writer, QNAME_ID_SUBSYSTEM_CODE, subsystemCode);
            }

            writeElement(writer, QNAME_ID_SERVICE_CODE, serviceId.getServiceCode());

            if (serviceId.getServiceVersion() != null) {
                String serviceVersion = serviceId.getServiceVersion();
                writeElement(writer, QNAME_ID_SERVICE_VERSION, serviceVersion);
            }

            char[] tabs = wrapperTabs != null ? wrapperTabs : new char[0];
            super.writeCharactersXml(tabs, 0, tabs.length, writer);
            super.writeEndElementXml(prefix, QNAME_XROAD_SERVICE, wrapperElementAttributes, writer);
        }

        @SneakyThrows
        private void writeElement(Writer writer, QName element, String value) {
            char[] tabs = nestedTabs != null ? nestedTabs : new char[0];

            super.writeCharactersXml(tabs, 0, tabs.length, writer);
            super.writeStartElementXml(nestedPrefix, element, nestedElementAttributes, writer);
            super.writeCharactersXml(value.toCharArray(), 0, value.length(), writer);
            super.writeEndElementXml(nestedPrefix, element, nestedElementAttributes, writer);
        }
    }
}