Merge pull request #93 from emeraldpay/feat/accesslog

access logging
This commit is contained in:
Igor Artamonov
2021-07-19 23:24:04 -04:00
committed by GitHub
27 changed files with 1328 additions and 38 deletions

View File

@@ -19,6 +19,8 @@ import com.fasterxml.jackson.core.Version
import com.fasterxml.jackson.databind.DeserializationFeature
import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.databind.module.SimpleModule
import com.fasterxml.jackson.datatype.jdk8.Jdk8Module
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule
import io.emeraldpay.dshackle.upstream.bitcoin.data.EsploraUnspent
import io.emeraldpay.dshackle.upstream.bitcoin.data.EsploraUnspentDeserializer
import io.emeraldpay.dshackle.upstream.bitcoin.data.RpcUnspent
@@ -49,6 +51,8 @@ class Global {
val objectMapper = ObjectMapper()
objectMapper.registerModule(module)
objectMapper.registerModule(Jdk8Module())
objectMapper.registerModule(JavaTimeModule())
objectMapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
objectMapper
.setDateFormat(SimpleDateFormat("yyyy-MM-dd\'T\'HH:mm:ss.SSS"))

View File

@@ -17,16 +17,11 @@
package io.emeraldpay.dshackle
import io.emeraldpay.dshackle.config.MainConfig
import io.emeraldpay.dshackle.monitoring.accesslog.AccessHandler
import io.grpc.*
import io.grpc.netty.GrpcSslContexts
import io.grpc.netty.NettyServerBuilder
import io.netty.handler.ssl.ClientAuth
import io.netty.handler.ssl.SslContext
import org.apache.commons.lang3.StringUtils
import org.slf4j.LoggerFactory
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.core.env.Environment
import org.springframework.core.io.ResourceLoader
import org.springframework.stereotype.Service
import java.net.InetSocketAddress
import javax.annotation.PostConstruct
@@ -36,7 +31,8 @@ import javax.annotation.PreDestroy
open class GrpcServer(
@Autowired val rpcs: List<io.grpc.BindableService>,
@Autowired val mainConfig: MainConfig,
@Autowired val tlsSetup: TlsSetup
@Autowired val tlsSetup: TlsSetup,
@Autowired val accessHandler: AccessHandler
) {
private val log = LoggerFactory.getLogger(GrpcServer::class.java)
@@ -50,6 +46,14 @@ open class GrpcServer(
log.info("Listening Native gRPC on ${mainConfig.host}:${mainConfig.port}")
val serverBuilder = NettyServerBuilder
.forAddress(InetSocketAddress(mainConfig.host, mainConfig.port))
.let {
if (mainConfig.accessLogConfig.enabled) {
it.intercept(accessHandler)
} else {
it
}
}
tlsSetup.setupServer("Native gRPC", mainConfig.tls, true)?.let {
serverBuilder.sslContext(it)
}

View File

@@ -0,0 +1,22 @@
package io.emeraldpay.dshackle.config
class AccessLogConfig(
val enabled: Boolean = false
) {
var filename: String = "./access_log.jsonl"
companion object {
fun default(): AccessLogConfig {
return disabled()
}
fun disabled(): AccessLogConfig {
return AccessLogConfig(
enabled = false
)
}
}
}

View File

@@ -0,0 +1,27 @@
package io.emeraldpay.dshackle.config
import org.slf4j.LoggerFactory
import org.yaml.snakeyaml.nodes.MappingNode
class AccessLogReader : YamlConfigReader(), ConfigReader<AccessLogConfig> {
companion object {
private val log = LoggerFactory.getLogger(AccessLogReader::class.java)
}
override fun read(input: MappingNode?): AccessLogConfig {
return getMapping(input, "accessLog")?.let { node ->
val enabled = getValueAsBool(node, "enabled") ?: false
if (!enabled) {
AccessLogConfig.disabled()
} else {
val config = AccessLogConfig(true)
getValueAsString(node, "filename")?.let {
config.filename = it
}
config
}
} ?: AccessLogConfig.default()
}
}

View File

@@ -24,5 +24,5 @@ class MainConfig {
var upstreams: UpstreamsConfig? = null
var tokens: TokensConfig? = null
var monitoring: MonitoringConfig = MonitoringConfig.default()
var accessLogConfig: AccessLogConfig = AccessLogConfig.default()
}

View File

@@ -34,6 +34,7 @@ class MainConfigReader(
private val cacheConfigReader = CacheConfigReader()
private val tokensConfigReader = TokensConfigReader()
private val monitoringConfigReader = MonitoringConfigReader()
private val accessLogReader = AccessLogReader()
fun read(input: InputStream): MainConfig? {
val configNode = readNode(input)
@@ -67,6 +68,9 @@ class MainConfigReader(
monitoringConfigReader.read(input).let {
config.monitoring = it
}
accessLogReader.read(input).let {
config.accessLogConfig = it
}
return config
}

View File

@@ -0,0 +1,174 @@
/**
* 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.api.proto.Common
import io.grpc.*
import org.slf4j.LoggerFactory
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.stereotype.Service
@Service
class AccessHandler(
@Autowired private val accessLogWriter: AccessLogWriter
) : ServerInterceptor {
companion object {
private val log = LoggerFactory.getLogger(AccessHandler::class.java)
}
override fun <ReqT : Any, RespT : Any> interceptCall(
call: ServerCall<ReqT, RespT>,
headers: Metadata,
next: ServerCallHandler<ReqT, RespT>): ServerCall.Listener<ReqT> {
return when (val method = call.methodDescriptor.bareMethodName) {
"SubscribeHead" -> processSubscribeHead(call, headers, next)
"SubscribeBalance" -> processSubscribeBalance(call, headers, next, true)
"SubscribeTxStatus" -> processSubscribeTxStatus(call, headers, next)
"GetBalance" -> processSubscribeBalance(call, headers, next, false)
"NativeCall" -> processNativeCall(call, headers, next)
"Describe" -> processDescribe(call, headers, next)
"SubscribeStatus" -> processStatus(call, headers, next)
else -> {
log.warn("unsupported method `{}`", method)
next.startCall(call, headers)
}
}
}
private fun <ReqT : Any, RespT : Any, E> process(
call: ServerCall<ReqT, RespT>,
headers: Metadata,
next: ServerCallHandler<ReqT, RespT>,
builder: EventsBuilder.RequestReply<E, ReqT, RespT>
): ServerCall.Listener<ReqT> {
builder.start(headers, call.attributes)
val callWrapper: ServerCall<ReqT, RespT> = StdCallResponse(
call, builder, accessLogWriter
)
return StdCallListener(
next.startCall(callWrapper, headers),
builder
)
}
@Suppress("UNCHECKED_CAST")
private fun <ReqT : Any, RespT : Any> processSubscribeHead(
call: ServerCall<ReqT, RespT>,
headers: Metadata,
next: ServerCallHandler<ReqT, RespT>
): ServerCall.Listener<ReqT> {
return process(call, headers, next,
EventsBuilder.SubscribeHead() as EventsBuilder.RequestReply<*, ReqT, RespT>
)
}
@Suppress("UNCHECKED_CAST")
private fun <ReqT : Any, RespT : Any> processSubscribeBalance(
call: ServerCall<ReqT, RespT>,
headers: Metadata,
next: ServerCallHandler<ReqT, RespT>,
subscribe: Boolean
): ServerCall.Listener<ReqT> {
return process(call, headers, next,
EventsBuilder.SubscribeBalance(subscribe) as EventsBuilder.RequestReply<*, ReqT, RespT>
)
}
@Suppress("UNCHECKED_CAST")
private fun <ReqT : Any, RespT : Any> processSubscribeTxStatus(
call: ServerCall<ReqT, RespT>,
headers: Metadata,
next: ServerCallHandler<ReqT, RespT>
): ServerCall.Listener<ReqT> {
return process(call, headers, next,
EventsBuilder.TxStatus() as EventsBuilder.RequestReply<*, ReqT, RespT>
)
}
@Suppress("UNCHECKED_CAST")
private fun <ReqT : Any, RespT : Any> processNativeCall(
call: ServerCall<ReqT, RespT>,
headers: Metadata,
next: ServerCallHandler<ReqT, RespT>
): ServerCall.Listener<ReqT> {
return process(call, headers, next,
EventsBuilder.NativeCall() as EventsBuilder.RequestReply<*, ReqT, RespT>
)
}
@Suppress("UNCHECKED_CAST")
private fun <ReqT : Any, RespT : Any> processDescribe(
call: ServerCall<ReqT, RespT>,
headers: Metadata,
next: ServerCallHandler<ReqT, RespT>
): ServerCall.Listener<ReqT> {
return process(call, headers, next,
EventsBuilder.Describe() as EventsBuilder.RequestReply<*, ReqT, RespT>
)
}
@Suppress("UNCHECKED_CAST")
private fun <ReqT : Any, RespT : Any> processStatus(
call: ServerCall<ReqT, RespT>,
headers: Metadata,
next: ServerCallHandler<ReqT, RespT>
): ServerCall.Listener<ReqT> {
return process(call, headers, next,
EventsBuilder.Status() as EventsBuilder.RequestReply<*, ReqT, RespT>
)
}
open class StdCallListener<Req, EB : EventsBuilder.RequestReply<*, Req, *>>(
val next: ServerCall.Listener<Req>,
val builder: EB
) : ForwardingServerCallListener<Req>() {
override fun onMessage(message: Req) {
builder.onRequest(message)
super.onMessage(message)
}
override fun delegate(): ServerCall.Listener<Req> {
return next
}
}
open class StdCallResponse<ReqT : Any, RespT : Any, EB : EventsBuilder.RequestReply<*, ReqT, RespT>>(
val next: ServerCall<ReqT, RespT>,
val builder: EB,
val accessLogWriter: AccessLogWriter
) : ForwardingServerCall<ReqT, RespT>() {
override fun getMethodDescriptor(): MethodDescriptor<ReqT, RespT> {
return next.methodDescriptor
}
override fun delegate(): ServerCall<ReqT, RespT> {
return next
}
override fun sendMessage(message: RespT) {
super.sendMessage(message)
accessLogWriter.submit(
builder.onReply(message)!!
)
}
}
}

View File

@@ -0,0 +1,124 @@
/**
* 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.dshackle.Global
import io.emeraldpay.dshackle.config.MainConfig
import org.slf4j.LoggerFactory
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.stereotype.Repository
import java.io.*
import java.time.Duration
import java.time.Instant
import java.util.concurrent.ConcurrentLinkedQueue
import java.util.concurrent.Executors
import java.util.concurrent.TimeUnit
import javax.annotation.PostConstruct
@Repository
class AccessLogWriter(
@Autowired mainConfig: MainConfig
) {
companion object {
private val log = LoggerFactory.getLogger(AccessLogWriter::class.java)
private const val WRITE_BATCH_LIMIT = 5000
private const val FLUSH_SLEEP_MS = 500L
private const val START_SLEEP_MS = 2000L
private val NL = "\n".toByteArray()
}
private val config = mainConfig.accessLogConfig
private val filename = File(config.filename)
private val scheduler = Executors.newSingleThreadScheduledExecutor()
private val queue = ConcurrentLinkedQueue<Any>()
private val objectMapper = Global.objectMapper
private var lastErrorAt: Instant = Instant.ofEpochMilli(0)
private val runner = Runnable {
flushRunner()
}
@PostConstruct
fun start() {
if (!config.enabled) {
log.info("Access Log is disabled")
return
}
log.info("Writing Access Log to ${filename.absolutePath}")
scheduler.schedule(runner, START_SLEEP_MS, TimeUnit.MILLISECONDS)
}
private fun flushRunner() {
try {
flush()
} catch (t: Throwable) {
logError {
log.error("Failed to write logs. ${t.javaClass}:${t.message}")
}
} finally {
scheduler.schedule(runner, FLUSH_SLEEP_MS, TimeUnit.MILLISECONDS)
}
}
fun submit(event: Any) {
queue.add(event)
}
fun submit(events: List<Any>) {
queue.addAll(events)
}
fun logError(m: () -> Unit) {
val now = Instant.now()
if (lastErrorAt.isBefore(now - Duration.ofMinutes(1))) {
lastErrorAt = now
m()
}
}
protected fun flush() {
if (!filename.exists()) {
if (!filename.createNewFile()) {
logError {
log.error("Cannot create Access Log file at ${filename.absolutePath}")
}
return
}
}
BufferedOutputStream(FileOutputStream(filename, true)).use { wrt ->
var limit = WRITE_BATCH_LIMIT
while (limit > 0) {
limit--
val next = queue.poll() ?: return
val bytes: ByteArray? = try {
objectMapper.writeValueAsBytes(next)
} catch (t: Throwable) {
logError {
log.warn("Failed to write an access log line. ${t.message}")
}
null
}
if (bytes != null && bytes.isNotEmpty()) {
wrt.write(bytes)
wrt.write(NL, 0, 1)
}
}
}
}
}

View File

@@ -0,0 +1,146 @@
/**
* 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 com.fasterxml.jackson.annotation.JsonInclude
import io.emeraldpay.grpc.Chain
import org.slf4j.LoggerFactory
import java.time.Instant
import java.util.*
class Events {
companion object {
private val log = LoggerFactory.getLogger(Events::class.java)
}
abstract class Base(
val id: UUID,
val method: String
) {
val version = "accesslog/v1beta"
val ts = Instant.now()
}
abstract class ChainBase(
val blockchain: Chain, method: String, id: UUID
) : Base(id, method)
@JsonInclude(JsonInclude.Include.NON_NULL)
class SubscribeHead(
blockchain: Chain, id: UUID,
// initial request details
val request: StreamRequestDetails,
// index of the current response
val index: Int
) : ChainBase(blockchain, "SubscribeHead", id)
@JsonInclude(JsonInclude.Include.NON_NULL)
class SubscribeBalance(
blockchain: Chain, id: UUID, subscribe: Boolean,
// initial request details
val request: StreamRequestDetails,
val balanceRequest: BalanceRequest,
val addressBalance: AddressBalance,
// index of the current response
val index: Int
) : ChainBase(blockchain, if (subscribe) "SubscribeBalance" else "GetBalance", id)
@JsonInclude(JsonInclude.Include.NON_NULL)
class TxStatus(
blockchain: Chain, id: UUID,
val request: StreamRequestDetails,
val txStatusRequest: TxStatusRequest,
val txStatus: TxStatusResponse,
// index of the current response
val index: Int
) : ChainBase(blockchain, "SubscribeTxStatus", id)
data class TxStatusRequest(
val txId: String
)
data class TxStatusResponse(
val confirmations: Int
)
@JsonInclude(JsonInclude.Include.NON_NULL)
class NativeCall(
blockchain: Chain, id: UUID,
// info about the initial request, that may include several native calls
val request: StreamRequestDetails,
// total native calls passes within the initial request
val total: Int,
// index of the call specific for the current response
val index: Int,
val selector: String? = null,
val quorum: Long? = null,
val minAvailability: String? = null,
val succeed: Boolean,
val rpcError: Int? = null,
val payloadSizeBytes: Long,
val nativeCall: NativeCallItemDetails
) : ChainBase(blockchain, "NativeCall", id)
@JsonInclude(JsonInclude.Include.NON_NULL)
class Describe(
id: UUID,
val request: StreamRequestDetails
) : Base(id, "Describe")
@JsonInclude(JsonInclude.Include.NON_NULL)
class Status(
blockchain: Chain, id: UUID,
val request: StreamRequestDetails
) : ChainBase(blockchain, "Status", id)
data class StreamRequestDetails(
val id: UUID,
val start: Instant,
val remote: Remote
)
data class Remote(
val ips: List<String>,
val ip: String,
val userAgent: String
)
data class NativeCallItemDetails(
val method: String,
val id: Int,
val payloadSizeBytes: Long
)
data class NativeCallReplyDetails(
val id: Int,
val succeed: Boolean,
val replySizeBytes: Long,
val ts: Instant = Instant.now()
)
data class BalanceRequest(
val asset: String,
val addressType: String
)
data class AddressBalance(
val asset: String,
val address: String
)
}

View File

@@ -0,0 +1,285 @@
/**
* 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.api.proto.Common
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.*
class EventsBuilder {
companion object {
private val log = LoggerFactory.getLogger(EventsBuilder::class.java)
}
interface StartingRequest {
fun start(metadata: Metadata, attributes: Attributes)
}
interface RequestReply<E, Req, Resp> : StartingRequest {
fun onRequest(msg: Req)
fun onReply(msg: Resp): E
}
abstract class Base<T>() : StartingRequest {
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]+")
}
var requestDetails = Events.StreamRequestDetails(
UUID.randomUUID(),
Instant.now(),
Events.Remote(emptyList(), "", "")
)
var chainId: Int = Chain.UNSPECIFIED.id
var chain = Chain.UNSPECIFIED
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>): 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()
}
protected abstract fun getT(): T
override fun start(metadata: Metadata, attributes: Attributes) {
val userAgent = metadata.get(Metadata.Key.of("user-agent", Metadata.ASCII_STRING_MARSHALLER))
?.let(this@Base::clean)
?: ""
val ips = ArrayList<InetAddress>()
remoteIpKeys.forEach { key ->
metadata.get(key)?.let {
it.trim().ifEmpty { null }
?.let(this@Base::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 = Events.Remote(
ips = ips.map { it.hostAddress },
ip = ip,
userAgent = userAgent
))
}
fun withChain(chain: Int): T {
this.chainId = chain
this.chain = Chain.byId(chainId)
return getT()
}
}
class SubscribeHead() :
Base<SubscribeHead>(),
RequestReply<Events.SubscribeHead, Common.Chain, BlockchainOuterClass.ChainHead> {
private var index = 0
override fun getT(): SubscribeHead {
return this
}
override fun onRequest(msg: Common.Chain) {
withChain(msg.type.number)
}
override fun onReply(msg: BlockchainOuterClass.ChainHead): Events.SubscribeHead {
return Events.SubscribeHead(
chain, UUID.randomUUID(), requestDetails, index++
)
}
}
class SubscribeBalance(val subscribe: Boolean) :
Base<SubscribeBalance>(),
RequestReply<Events.SubscribeBalance, BlockchainOuterClass.BalanceRequest, BlockchainOuterClass.AddressBalance> {
private var index = 0
private var balanceRequest: Events.BalanceRequest? = null
override fun getT(): SubscribeBalance {
return this
}
override fun onRequest(msg: BlockchainOuterClass.BalanceRequest) {
balanceRequest = Events.BalanceRequest(
msg.asset.code.toUpperCase(),
msg.address.addrTypeCase.name
)
}
override fun onReply(msg: BlockchainOuterClass.AddressBalance): Events.SubscribeBalance {
if (balanceRequest == null) {
throw IllegalStateException("Request is not initialized")
}
val addressBalance = Events.AddressBalance(msg.asset.code, msg.address.address)
val chain = Chain.byId(msg.asset.chain.number)
return Events.SubscribeBalance(
chain, UUID.randomUUID(), subscribe, requestDetails, balanceRequest!!, addressBalance, index++
)
}
}
class TxStatus() :
Base<TxStatus>(),
RequestReply<Events.TxStatus, BlockchainOuterClass.TxStatusRequest, BlockchainOuterClass.TxStatus> {
private var index = 0
private var txStatusRequest: Events.TxStatusRequest? = null
override fun onRequest(msg: BlockchainOuterClass.TxStatusRequest) {
this.txStatusRequest = Events.TxStatusRequest(msg.txId)
withChain(msg.chainValue)
}
override fun onReply(msg: BlockchainOuterClass.TxStatus): Events.TxStatus {
return Events.TxStatus(
chain, UUID.randomUUID(), requestDetails, txStatusRequest!!,
Events.TxStatusResponse(msg.confirmations),
index++
)
}
override fun getT(): TxStatus {
return this
}
}
class NativeCall :
Base<NativeCall>(),
RequestReply<Events.NativeCall, BlockchainOuterClass.NativeCallRequest, BlockchainOuterClass.NativeCallReplyItem> {
val items = ArrayList<Events.NativeCallItemDetails>()
val replies = HashMap<Int, Events.NativeCallReplyDetails>()
private var index = 0
override fun getT(): NativeCall {
return this
}
override fun onRequest(msg: BlockchainOuterClass.NativeCallRequest) {
withChain(msg.chain.number)
msg.itemsList.forEach { item ->
this.items.add(
Events.NativeCallItemDetails(
item.method,
item.id,
item.payload.size().toLong()
)
)
}
}
override fun onReply(msg: BlockchainOuterClass.NativeCallReplyItem): Events.NativeCall {
val item = items.find { it.id == msg.id }!!
return Events.NativeCall(
request = requestDetails,
total = items.size,
index = index++,
succeed = msg.succeed,
blockchain = chain,
nativeCall = item,
payloadSizeBytes = item.payloadSizeBytes,
id = UUID.randomUUID()
)
}
}
class Describe :
Base<Describe>(),
RequestReply<Events.Describe, BlockchainOuterClass.DescribeRequest, BlockchainOuterClass.DescribeResponse> {
override fun getT(): Describe {
return this
}
override fun onRequest(msg: BlockchainOuterClass.DescribeRequest) {
}
override fun onReply(msg: BlockchainOuterClass.DescribeResponse): Events.Describe {
return Events.Describe(
id = UUID.randomUUID(),
request = requestDetails
)
}
}
class Status :
Base<Status>(),
RequestReply<Events.Status, BlockchainOuterClass.StatusRequest, BlockchainOuterClass.ChainStatus> {
override fun getT(): Status {
return this
}
override fun onRequest(msg: BlockchainOuterClass.StatusRequest) {
}
override fun onReply(msg: BlockchainOuterClass.ChainStatus): Events.Status {
val chain = Chain.byId(msg.chainValue)
return Events.Status(
blockchain = chain,
request = requestDetails,
id = UUID.randomUUID()
)
}
}
}

View File

@@ -21,6 +21,7 @@ import io.emeraldpay.dshackle.config.UpstreamsConfig
import io.emeraldpay.dshackle.startup.QuorumForLabels
import io.emeraldpay.dshackle.upstream.calls.CallMethods
import reactor.core.publisher.Flux
import reactor.core.publisher.Sinks
import reactor.extra.processor.TopicProcessor
import java.util.concurrent.atomic.AtomicReference
@@ -41,7 +42,9 @@ abstract class DefaultUpstream(
this(id, Long.MAX_VALUE, UpstreamAvailability.UNAVAILABLE, options, role, targets, node)
private val status = AtomicReference(Status(defaultLag, defaultAvail, statusByLag(defaultLag, defaultAvail)))
private val statusStream: TopicProcessor<UpstreamAvailability> = TopicProcessor.create()
private val statusStream = Sinks.many()
.multicast()
.directBestEffort<UpstreamAvailability>()
override fun isAvailable(): Boolean {
return getStatus() == UpstreamAvailability.OK
@@ -63,6 +66,7 @@ abstract class DefaultUpstream(
status.updateAndGet { curr ->
Status(curr.lag, avail, statusByLag(curr.lag, avail))
}
statusStream.tryEmitNext(status.get().status)
}
fun statusByLag(lag: Long, proposed: UpstreamAvailability): UpstreamAvailability {
@@ -76,7 +80,8 @@ abstract class DefaultUpstream(
}
override fun observeStatus(): Flux<UpstreamAvailability> {
return Flux.from(statusStream)
return statusStream.asFlux()
.distinctUntilChanged()
}
override fun setLag(lag: Long) {
@@ -86,6 +91,7 @@ abstract class DefaultUpstream(
status.updateAndGet { curr ->
Status(lag, curr.avail, statusByLag(lag, curr.avail))
}
statusStream.tryEmitNext(status.get().status)
}
}

View File

@@ -168,7 +168,12 @@ abstract class Multistream(
}
override fun observeStatus(): Flux<UpstreamAvailability> {
val upstreamsFluxes = getAll().map { up -> up.observeStatus().map { UpstreamStatus(up, it) } }
val upstreamsFluxes = getAll().map { up ->
Flux.concat(
Mono.just(up.getStatus()),
up.observeStatus()
).map { UpstreamStatus(up, it) }
}
return Flux.merge(upstreamsFluxes)
.filter(FilterBestAvailability())
.map { it.status }

View File

@@ -0,0 +1,53 @@
package io.emeraldpay.dshackle.monitoring.accesslog
import com.fasterxml.jackson.databind.ObjectMapper
import io.emeraldpay.dshackle.Global
import io.emeraldpay.dshackle.config.AccessLogConfig
import io.emeraldpay.dshackle.config.MainConfig
import io.emeraldpay.grpc.Chain
import spock.lang.Specification
import java.time.Instant
class AccessLogWriterSpec extends Specification {
def "writes log event"() {
setup:
File dir = File.createTempDir("dshackle-test-")
File accessLog = new File(dir, "accesslog.jsonl")
println("Write access log to $accessLog.absolutePath")
MainConfig config = new MainConfig()
config.accessLogConfig = new AccessLogConfig(true).tap {
it.filename = accessLog.absolutePath
}
AccessLogWriter logWriter = new AccessLogWriter(config)
when:
def event = new Events.Status(
Chain.ETHEREUM, UUID.fromString("9d8ecbf3-12fb-49cf-af9d-949a1050a000"),
new Events.StreamRequestDetails(
UUID.fromString("9d8ecbf3-12fb-49cf-af9d-949a1050a000"),
Instant.ofEpochMilli(1626746880123),
new Events.Remote(
["127.0.0.1", "172.217.8.78"], "172.217.8.78", "UnitTest"
)
)
)
logWriter.submit([event])
logWriter.flush()
def act = accessLog.readLines()
then:
act.size() == 1
with(act[0]) {
def json = Global.objectMapper.readValue(it, Map)
json["version"] == "accesslog/v1beta"
json["id"] == "9d8ecbf3-12fb-49cf-af9d-949a1050a000"
json["method"] == "Status"
json["blockchain"] == "ETHEREUM"
json["request"]["start"] == "2021-07-20T02:08:00.123Z"
json["request"]["id"] == "9d8ecbf3-12fb-49cf-af9d-949a1050a000"
json["request"]["remote"]["ip"] == "172.217.8.78"
json["request"]["remote"]["userAgent"] == "UnitTest"
}
}
}

View File

@@ -0,0 +1,235 @@
/**
* 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 org.jetbrains.annotations.NotNull
import org.junit.validator.TestClassValidator
import spock.lang.Specification
class EventsBaseBuilderSpec extends Specification {
class TestEvent extends Events.Base {
Events.StreamRequestDetails request
TestEvent(Events.StreamRequestDetails request) {
super(UUID.randomUUID(), "TEST")
this.request = request
}
}
class TestEventBuilder extends EventsBuilder.Base<TestEventBuilder>
implements EventsBuilder.RequestReply<TestEvent, BlockchainOuterClass.NativeCallRequest, BlockchainOuterClass.NativeCallReplyItem> {
@Override
protected TestEventBuilder getT() {
return this
}
@Override
void onRequest(BlockchainOuterClass.NativeCallRequest msg) {
}
@Override
TestEvent onReply(BlockchainOuterClass.NativeCallReplyItem msg) {
return new TestEvent(requestDetails)
}
}
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 TestEventBuilder()
.tap {
it.start(metadata, attributes)
it.onRequest(BlockchainOuterClass.NativeCallRequest.getDefaultInstance())
}
.onReply(BlockchainOuterClass.NativeCallReplyItem.getDefaultInstance())
then:
act.request != null
act.request.remote != null
with(act.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 TestEventBuilder()
.tap {
it.start(metadata, attributes)
it.onRequest(BlockchainOuterClass.NativeCallRequest.getDefaultInstance())
}
.onReply(BlockchainOuterClass.NativeCallReplyItem.getDefaultInstance())
then:
with(act.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 TestEventBuilder()
.tap {
it.start(metadata, attributes)
it.onRequest(BlockchainOuterClass.NativeCallRequest.getDefaultInstance())
}
.onReply(BlockchainOuterClass.NativeCallReplyItem.getDefaultInstance())
then:
with(act.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 TestEventBuilder()
.tap {
it.start(metadata, attributes)
it.onRequest(BlockchainOuterClass.NativeCallRequest.getDefaultInstance())
}
.onReply(BlockchainOuterClass.NativeCallReplyItem.getDefaultInstance())
then:
with(act.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 TestEventBuilder()
.tap {
it.start(metadata, attributes)
it.onRequest(BlockchainOuterClass.NativeCallRequest.getDefaultInstance())
}
.onReply(BlockchainOuterClass.NativeCallReplyItem.getDefaultInstance())
then:
with(act.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 TestEventBuilder()
.tap {
it.start(metadata, attributes)
it.onRequest(BlockchainOuterClass.NativeCallRequest.getDefaultInstance())
}
.onReply(BlockchainOuterClass.NativeCallReplyItem.getDefaultInstance())
then:
with(act.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 TestEventBuilder()
.tap {
it.start(metadata, attributes)
it.onRequest(BlockchainOuterClass.NativeCallRequest.getDefaultInstance())
}
.onReply(BlockchainOuterClass.NativeCallReplyItem.getDefaultInstance())
then:
with(act.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 TestEventBuilder()
.tap {
it.start(metadata, attributes)
it.onRequest(BlockchainOuterClass.NativeCallRequest.getDefaultInstance())
}
.onReply(BlockchainOuterClass.NativeCallReplyItem.getDefaultInstance())
then:
with(act.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_"
}
}
}

View File

@@ -0,0 +1,83 @@
package io.emeraldpay.dshackle.monitoring.accesslog
import io.emeraldpay.api.proto.BlockchainOuterClass
import io.emeraldpay.api.proto.Common
import io.emeraldpay.grpc.Chain
import spock.lang.Specification
class EventsBuilderSubscribeBalanceSpec extends Specification {
def "Basic ethereum event"() {
setup:
def request = BlockchainOuterClass.BalanceRequest.newBuilder()
.setAddress(
Common.AnyAddress.newBuilder()
.setAddressSingle(
Common.SingleAddress.newBuilder()
.setAddress("0x7a250d5630B4cF539739dF2C5dAcb4c659F2488D")
)
)
.setAsset(
Common.Asset.newBuilder()
.setChainValue(100)
.setCode("ETHER")
)
.build()
def resp = BlockchainOuterClass.AddressBalance.newBuilder()
.setAddress(Common.SingleAddress.newBuilder()
.setAddress("0x7a250d5630B4cF539739dF2C5dAcb4c659F2488D"))
.setAsset(Common.Asset.newBuilder()
.setChainValue(100)
.setCode("ETHER"))
.setBalance("1234560000000000000")
.build()
when:
def act = new EventsBuilder.SubscribeBalance(true).tap {
it.onRequest(request)
}.onReply(resp)
then:
act.index == 0
act.blockchain == Chain.ETHEREUM
act.balanceRequest.asset == "ETHER"
act.balanceRequest.addressType == "ADDRESS_SINGLE"
act.addressBalance.asset == "ETHER"
act.addressBalance.address == "0x7a250d5630B4cF539739dF2C5dAcb4c659F2488D"
}
def "Basic bitcoin event"() {
setup:
def request = BlockchainOuterClass.BalanceRequest.newBuilder()
.setAddress(
Common.AnyAddress.newBuilder()
.setAddressSingle(
Common.SingleAddress.newBuilder()
.setAddress("1NDyJtNTjmwk5xPNhjgAMu4HDHigtobu1s")
)
)
.setAsset(
Common.Asset.newBuilder()
.setChainValue(1)
.setCode("BTC")
)
.build()
def resp = BlockchainOuterClass.AddressBalance.newBuilder()
.setAddress(Common.SingleAddress.newBuilder()
.setAddress("1NDyJtNTjmwk5xPNhjgAMu4HDHigtobu1s"))
.setAsset(Common.Asset.newBuilder()
.setChainValue(1)
.setCode("BTC"))
.setBalance("12345600000000")
.build()
when:
def act = new EventsBuilder.SubscribeBalance(true).tap {
it.onRequest(request)
}.onReply(resp)
then:
act.index == 0
act.blockchain == Chain.BITCOIN
act.balanceRequest.asset == "BTC"
act.balanceRequest.addressType == "ADDRESS_SINGLE"
act.addressBalance.asset == "BTC"
act.addressBalance.address == "1NDyJtNTjmwk5xPNhjgAMu4HDHigtobu1s"
}
}