diff --git a/src/main/kotlin/io/emeraldpay/dshackle/monitoring/accesslog/AccessHandler.kt b/src/main/kotlin/io/emeraldpay/dshackle/monitoring/accesslog/AccessHandler.kt index 5a4aadf8..0c207ec2 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/monitoring/accesslog/AccessHandler.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/monitoring/accesslog/AccessHandler.kt @@ -16,7 +16,6 @@ package io.emeraldpay.dshackle.monitoring.accesslog import io.emeraldpay.api.proto.BlockchainOuterClass -import io.emeraldpay.dshackle.Global import io.grpc.* import org.slf4j.LoggerFactory import org.springframework.beans.factory.annotation.Autowired @@ -39,6 +38,7 @@ class AccessHandler( when (val method = call.methodDescriptor.bareMethodName) { "NativeCall" -> { val builder = Events.NativeCallBuilder() + .start(headers, call.attributes) return OnNativeCall( next.startCall(OnNativeCallResponse(call, builder), headers), builder) { logs -> diff --git a/src/main/kotlin/io/emeraldpay/dshackle/monitoring/accesslog/AccessLogWriter.kt b/src/main/kotlin/io/emeraldpay/dshackle/monitoring/accesslog/AccessLogWriter.kt index 93340a25..3573952d 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/monitoring/accesslog/AccessLogWriter.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/monitoring/accesslog/AccessLogWriter.kt @@ -56,7 +56,7 @@ class AccessLogWriter( @PostConstruct fun start() { if (!config.enabled) { - log.info("Access Log is diabled") + log.info("Access Log is disabled") return } log.info("Writing Access Log to ${filename.absolutePath}") diff --git a/src/main/kotlin/io/emeraldpay/dshackle/monitoring/accesslog/Events.kt b/src/main/kotlin/io/emeraldpay/dshackle/monitoring/accesslog/Events.kt index 6dc289f1..a26cade0 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/monitoring/accesslog/Events.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/monitoring/accesslog/Events.kt @@ -18,10 +18,15 @@ package io.emeraldpay.dshackle.monitoring.accesslog import com.fasterxml.jackson.annotation.JsonInclude import io.emeraldpay.api.proto.BlockchainOuterClass import io.emeraldpay.grpc.Chain +import io.grpc.Attributes +import io.grpc.Grpc +import io.grpc.Metadata +import org.apache.commons.lang3.StringUtils import org.slf4j.LoggerFactory +import java.net.InetAddress +import java.net.InetSocketAddress import java.time.Instant import java.util.* -import kotlin.collections.ArrayList class Events { @@ -65,11 +70,13 @@ class Events { data class StreamRequestDetails( val id: UUID, - val start: Instant + val start: Instant, + val remote: Remote ) data class Remote( val ips: List, + val ip: String, val userAgent: String ) @@ -88,15 +95,82 @@ class Events { class NativeCallBuilder() { - private val requestDetails = StreamRequestDetails( + companion object { + private val remoteIpKeys = listOf( + Metadata.Key.of("x-real-ip", Metadata.ASCII_STRING_MARSHALLER), + Metadata.Key.of("x-forwarded-for", Metadata.ASCII_STRING_MARSHALLER) + ) + private val invalidCharacters = Regex("[\n\t]+") + } + + private var requestDetails = StreamRequestDetails( UUID.randomUUID(), - Instant.now() + Instant.now(), + Remote(emptyList(), "", "") ) var chain: Int = Chain.UNSPECIFIED.id val items = ArrayList() val replies = HashMap() + private fun toInetAddress(ip: String): InetAddress? { + val isIp = Character.digit(ip[0], 16) != -1 + if (!isIp) { + return null + } + return try { + InetAddress.getByName(ip) + } catch (t: Throwable) { + null + } + } + + private fun findBestIp(ips: List): InetAddress? { + // check if a real remote address is provided, otherwise use any local address + return ips.sortedWith(kotlin.Comparator { a, b -> + val aLocal = a.isLoopbackAddress || a.isSiteLocalAddress + val bLocal = b.isLoopbackAddress || b.isSiteLocalAddress + when { + aLocal && bLocal -> 0 + aLocal -> 1 + else -> -1 + } + }).firstOrNull() + } + + private fun clean(s: String): String { + return StringUtils.truncate(s, 128) + .replace(invalidCharacters, " ") + .trim() + } + + fun start(metadata: Metadata, attributes: Attributes): NativeCallBuilder { + val userAgent = metadata.get(Metadata.Key.of("user-agent", Metadata.ASCII_STRING_MARSHALLER)) + ?.let(this@NativeCallBuilder::clean) + ?: "" + val ips = ArrayList() + remoteIpKeys.forEach { key -> + metadata.get(key)?.let { + it.trim().ifEmpty { null } + ?.let(this@NativeCallBuilder::toInetAddress) + ?.let(ips::add) + } + } + attributes.get(Grpc.TRANSPORT_ATTR_REMOTE_ADDR)?.let { addr -> + if (addr is InetSocketAddress) { + ips.add(addr.address) + } + } + val ip = findBestIp(ips)?.hostAddress ?: "" + this.requestDetails = this.requestDetails + .copy(remote = Remote( + ips = ips.map { it.hostAddress }, + ip = ip, + userAgent = userAgent + )) + return this + } + fun withChain(chain: Int): NativeCallBuilder { this.chain = chain return this diff --git a/src/test/groovy/io/emeraldpay/dshackle/monitoring/accesslog/EventsNativeCallBuilderSpec.groovy b/src/test/groovy/io/emeraldpay/dshackle/monitoring/accesslog/EventsNativeCallBuilderSpec.groovy new file mode 100644 index 00000000..ef92c58a --- /dev/null +++ b/src/test/groovy/io/emeraldpay/dshackle/monitoring/accesslog/EventsNativeCallBuilderSpec.groovy @@ -0,0 +1,207 @@ +/** + * Copyright (c) 2021 EmeraldPay, Inc + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.emeraldpay.dshackle.monitoring.accesslog + +import io.emeraldpay.api.proto.BlockchainOuterClass +import io.emeraldpay.grpc.Chain +import io.grpc.Attributes +import io.grpc.Grpc +import io.grpc.Metadata +import spock.lang.Specification + +class EventsNativeCallBuilderSpec extends Specification { + + def "Parse headers from direct local access"() { + setup: + def metadata = new Metadata() + metadata.put(Metadata.Key.of("user-agent", Metadata.ASCII_STRING_MARSHALLER), "grpc-go/1.30.0") + def attributes = Attributes.newBuilder() + .set(Grpc.TRANSPORT_ATTR_REMOTE_ADDR, new InetSocketAddress(Inet4Address.getByName("127.0.0.1"), 2448)) + .build() + when: + def act = new Events.NativeCallBuilder() + .start(metadata, attributes) + .withChain(Chain.ETHEREUM.id) + .onItem(BlockchainOuterClass.NativeCallItem.getDefaultInstance()) + .build() + then: + act.size() == 1 + with(act[0]) { + it.request != null + it.request.remote != null + with(it.request.remote) { + ips == ["127.0.0.1"] + userAgent == "grpc-go/1.30.0" + ip == "127.0.0.1" + } + } + } + + def "Extracts real remote ip"() { + setup: + def metadata = new Metadata() + metadata.put(Metadata.Key.of("user-agent", Metadata.ASCII_STRING_MARSHALLER), "grpc-go/1.30.0") + metadata.put(Metadata.Key.of("x-real-ip", Metadata.ASCII_STRING_MARSHALLER), "30.56.100.15") + def attributes = Attributes.newBuilder() + .set(Grpc.TRANSPORT_ATTR_REMOTE_ADDR, new InetSocketAddress(Inet4Address.getByName("127.0.0.1"), 2448)) + .build() + when: + def act = new Events.NativeCallBuilder() + .start(metadata, attributes) + .withChain(Chain.ETHEREUM.id) + .onItem(BlockchainOuterClass.NativeCallItem.getDefaultInstance()) + .build() + then: + act.size() == 1 + with(act[0].request.remote) { + ips == ["30.56.100.15", "127.0.0.1"] + ip == "30.56.100.15" + } + } + + def "Ignores remote ip header if already connected from remote"() { + setup: + def metadata = new Metadata() + metadata.put(Metadata.Key.of("user-agent", Metadata.ASCII_STRING_MARSHALLER), "grpc-go/1.30.0") + metadata.put(Metadata.Key.of("x-real-ip", Metadata.ASCII_STRING_MARSHALLER), "192.168.1.1") + def attributes = Attributes.newBuilder() + .set(Grpc.TRANSPORT_ATTR_REMOTE_ADDR, new InetSocketAddress(Inet4Address.getByName("30.56.100.15"), 2448)) + .build() + when: + def act = new Events.NativeCallBuilder() + .start(metadata, attributes) + .withChain(Chain.ETHEREUM.id) + .onItem(BlockchainOuterClass.NativeCallItem.getDefaultInstance()) + .build() + then: + act.size() == 1 + with(act[0].request.remote) { + ips == ["192.168.1.1", "30.56.100.15"] + userAgent == "grpc-go/1.30.0" + ip == "30.56.100.15" + } + } + + def "Ignores invalid ip header"() { + setup: + def metadata = new Metadata() + metadata.put(Metadata.Key.of("user-agent", Metadata.ASCII_STRING_MARSHALLER), "grpc-go/1.30.0") + metadata.put(Metadata.Key.of("x-real-ip", Metadata.ASCII_STRING_MARSHALLER), "271.194.19.1") + def attributes = Attributes.newBuilder() + .set(Grpc.TRANSPORT_ATTR_REMOTE_ADDR, new InetSocketAddress(Inet4Address.getByName("30.56.100.15"), 2448)) + .build() + when: + def act = new Events.NativeCallBuilder() + .start(metadata, attributes) + .withChain(Chain.ETHEREUM.id) + .onItem(BlockchainOuterClass.NativeCallItem.getDefaultInstance()) + .build() + then: + act.size() == 1 + with(act[0].request.remote) { + ips == ["30.56.100.15"] + userAgent == "grpc-go/1.30.0" + ip == "30.56.100.15" + } + } + + def "Ignores host addr in ip header"() { + setup: + def metadata = new Metadata() + metadata.put(Metadata.Key.of("user-agent", Metadata.ASCII_STRING_MARSHALLER), "grpc-go/1.30.0") + metadata.put(Metadata.Key.of("x-real-ip", Metadata.ASCII_STRING_MARSHALLER), "google.com") + def attributes = Attributes.newBuilder() + .set(Grpc.TRANSPORT_ATTR_REMOTE_ADDR, new InetSocketAddress(Inet4Address.getByName("30.56.100.15"), 2448)) + .build() + when: + def act = new Events.NativeCallBuilder() + .start(metadata, attributes) + .withChain(Chain.ETHEREUM.id) + .onItem(BlockchainOuterClass.NativeCallItem.getDefaultInstance()) + .build() + then: + act.size() == 1 + with(act[0].request.remote) { + ips == ["30.56.100.15"] + userAgent == "grpc-go/1.30.0" + ip == "30.56.100.15" + } + } + + def "Extracts ipv6 addresses"() { + setup: + def metadata = new Metadata() + metadata.put(Metadata.Key.of("user-agent", Metadata.ASCII_STRING_MARSHALLER), "grpc-go/1.30.0") + metadata.put(Metadata.Key.of("x-real-ip", Metadata.ASCII_STRING_MARSHALLER), "2001:0db8:0000:0000:0000:ff00:0042:8329") + def attributes = Attributes.newBuilder() + .set(Grpc.TRANSPORT_ATTR_REMOTE_ADDR, new InetSocketAddress(Inet6Address.getByName("::1"), 2448)) + .build() + when: + def act = new Events.NativeCallBuilder() + .start(metadata, attributes) + .withChain(Chain.ETHEREUM.id) + .onItem(BlockchainOuterClass.NativeCallItem.getDefaultInstance()) + .build() + then: + act.size() == 1 + with(act[0].request.remote) { + ips == ["2001:db8:0:0:0:ff00:42:8329", "0:0:0:0:0:0:0:1"] + ip == "2001:db8:0:0:0:ff00:42:8329" + } + } + + def "Cleans up user agent"() { + setup: + def metadata = new Metadata() + metadata.put(Metadata.Key.of("user-agent", Metadata.ASCII_STRING_MARSHALLER), "grpc-go/1.30.0\nxss\n\r") + def attributes = Attributes.newBuilder() + .set(Grpc.TRANSPORT_ATTR_REMOTE_ADDR, new InetSocketAddress(Inet4Address.getByName("30.56.100.15"), 2448)) + .build() + when: + def act = new Events.NativeCallBuilder() + .start(metadata, attributes) + .withChain(Chain.ETHEREUM.id) + .onItem(BlockchainOuterClass.NativeCallItem.getDefaultInstance()) + .build() + then: + act.size() == 1 + with(act[0].request.remote) { + userAgent == "grpc-go/1.30.0 xss" + } + } + + def "Truncates up user agent to 128 characters max"() { + setup: + def metadata = new Metadata() + metadata.put(Metadata.Key.of("user-agent", Metadata.ASCII_STRING_MARSHALLER), + "0123456_1_0123456_2_0123456_3_0123456_4_0123456_5_0123456_6_0123456_7_0123456_8_0123456_9_0123456_0_0123456_1_0123456_2_0123456_3_0123456_4_0123456_5") + def attributes = Attributes.newBuilder() + .set(Grpc.TRANSPORT_ATTR_REMOTE_ADDR, new InetSocketAddress(Inet4Address.getByName("30.56.100.15"), 2448)) + .build() + when: + def act = new Events.NativeCallBuilder() + .start(metadata, attributes) + .withChain(Chain.ETHEREUM.id) + .onItem(BlockchainOuterClass.NativeCallItem.getDefaultInstance()) + .build() + then: + act.size() == 1 + with(act[0].request.remote) { + userAgent.length() == 128 + userAgent == "0123456_1_0123456_2_0123456_3_0123456_4_0123456_5_0123456_6_0123456_7_0123456_8_0123456_9_0123456_0_0123456_1_0123456_2_0123456_" + } + } +}