Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Prototype of the remote boefje runner #3223

Closed
wants to merge 17 commits into from
Closed
Show file tree
Hide file tree
Changes from 12 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions boefjes/Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ images: # Build the images for the containerized boefjes
docker build -f ./boefjes/plugins/kat_nmap_tcp/boefje.Dockerfile -t openkat/nmap .



##
##|------------------------------------------------------------------------|
## Migrations
Expand Down
4 changes: 2 additions & 2 deletions boefjes/boefjes/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -159,7 +159,7 @@ def create_boefje_meta(task, local_repository):
arguments = {"oci_arguments": boefje_resource.oci_arguments}

if input_ooi:
reference = Reference.from_str(input_ooi)
reference = Reference.from_str(input_ooi) # TODO SOUF why is this here? Just giving `input_ooi` works too
try:
ooi = get_octopoes_api_connector(organization).get(reference, valid_time=datetime.now(timezone.utc))
except ObjectNotFoundException as e:
Expand All @@ -170,7 +170,7 @@ def create_boefje_meta(task, local_repository):
boefje_meta = BoefjeMeta(
id=task.id,
boefje=boefje,
input_ooi=input_ooi,
input_ooi=input_ooi, # `input_ooi` is of type str, and we just give it an instance here?
arguments=arguments,
organization=organization,
environment=environment,
Expand Down
4 changes: 1 addition & 3 deletions boefjes/boefjes/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -99,10 +99,8 @@ def _fill_queue(self, task_queue: Queue, queue_type: WorkerManager.Queue):
all_queues_empty = True

for queue_type in queues:
logger.debug("Popping from queue %s", queue_type.id)

try:
p_item = self.scheduler_client.pop_item(queue_type.id)
p_item = self.scheduler_client.pop_non_remote_item(queue_type.id)
except (HTTPError, ValidationError):
logger.exception("Popping task from scheduler failed, sleeping 10 seconds")
time.sleep(10)
Expand Down
22 changes: 19 additions & 3 deletions boefjes/boefjes/clients/scheduler_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import logging
import uuid
from enum import Enum
from typing import Literal

from httpx import Client, HTTPTransport, Response
from pydantic import BaseModel, TypeAdapter
Expand All @@ -26,6 +27,7 @@ class QueuePrioritizedItem(BaseModel):
priority: int
hash: str | None = None
data: BoefjeMeta | NormalizerMeta
remote: bool


class TaskStatus(Enum):
Expand All @@ -47,13 +49,26 @@ class Task(BaseModel):
status: TaskStatus
created_at: datetime.datetime
modified_at: datetime.datetime
remote: bool


# TODO: SOUF ask where to put this
class Filter(BaseModel):
column: str
field: str | None = None
operator: Literal["=="] = "=="
value: bool


class QueuePopModel(BaseModel):
filters: list[Filter]


class SchedulerClientInterface:
def get_queues(self) -> list[Queue]:
raise NotImplementedError()

def pop_item(self, queue: str) -> QueuePrioritizedItem | None:
def pop_non_remote_item(self, queue: str) -> QueuePrioritizedItem | None:
raise NotImplementedError()

def patch_task(self, task_id: uuid.UUID, status: TaskStatus) -> None:
Expand All @@ -80,8 +95,9 @@ def get_queues(self) -> list[Queue]:

return TypeAdapter(list[Queue]).validate_json(response.content)

def pop_item(self, queue: str) -> QueuePrioritizedItem | None:
response = self._session.post(f"/queues/{queue}/pop")
def pop_non_remote_item(self, queue: str) -> QueuePrioritizedItem | None:
non_remote_filter = QueuePopModel(filters=[Filter(column="remote", operator="==", value=False)])
response = self._session.post(f"/queues/{queue}/pop", json=non_remote_filter.model_dump())
Comment on lines +98 to +100
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think its better to keep the pop_item name the same, but configure the self._session (eg, the schedulerclient) to have a different url on which it can find the scheduler for remote runners.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Instead of creating a new url or changing the method's name, I have thought of adding a new parameter to pop_item that would change the query. The change would look like something like this:

def pop_non_remote_item(self, queue: str) -> QueuePrioritizedItem | None:
	non_remote_filter = QueuePopModel(filters=[Filter(column="remote", operator="==", value=False)])
	response = self._session.post(f"/queues/{queue}/pop", json=non_remote_filter.model_dump())

=>

def pop_item(self, queue: str, remote: bool = False) -> QueuePrioritizedItem | None:
	non_remote_filter = QueuePopModel(filters=[Filter(column="remote", operator="==", value=remote)])
	response = self._session.post(f"/queues/{queue}/pop", json=non_remote_filter.model_dump())

(added a new parameter used for the filter and changed the name back)

Or do you think it'd be better to add a new URLs to the scheduler which would only pop non-remote tasks and remote tasks?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Its the other way round.
The Scheduler is located on one location. and has one url (possibly two if you where to use a local url and an external url).

It's the boefjes runners that are hosted somewhere else. They need to contact the scheduler for Jobs. to do so, they need to know the url for the scheduler (eg, remote accessible, or local url).
With that request for jobs they need to send any limitations they themselves know about. Eg, Can they reach network X, can they reach internet, Can they perform ipv6 lookups etc.
Which limitations they have, and where the scheduler can be found needs to be configurable per Boefje Runner in a config file they can read locally (or via Env vars)

The scheduler then returns this filtered list of jobs, and the boefje runner starts processing them.
Once done, the boefje needs to return it's raw files to Bytes. For that a url/path to bytes also needs to be present, much in the same way the url/path to the scheduler needs to be present.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Scheduler is located on one location. and has one url (possibly two if you where to use a local url and an external url).

I don't understand what you mean with the scheduler potentially having 2 URLs.
Do you mean with "local url" and "external url" for example http://localhost:8004 and https://www.example.com/scheduler?

With that request for jobs they need to send any limitations they themselves know about. Eg, Can they reach network X, can they reach internet, Can they perform ipv6 lookups etc.

Would the information about those jobs all be inside the same tasks-queue that the current boefje-runner uses?
(With "boefjes runner" you mean the BoefjeHandler I assume).

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yup, for some the scheduler can be found on localhost, or on a local IP, for others it might be located on https://somesite.com/scheduler A boefje runner needs one to contact the scheduler for jobs, and one to send raw files to bytes once the jobs are done.
The task-queue exists in the scheduler, the boefje runner just picks up jobs from the scheduler and executes them locally. A boefje runner does not have more than one job active per thread at the moment, so no queue exists there.

self._verify_response(response)

return TypeAdapter(QueuePrioritizedItem | None).validate_json(response.content)
Expand Down
22 changes: 17 additions & 5 deletions boefjes/boefjes/job_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
from boefjes.job_models import BoefjeMeta, NormalizerMeta, SerializedOOI, SerializedOOIValue
from boefjes.katalogus.local_repository import LocalPluginRepository
from boefjes.plugins.models import _default_mime_types
from boefjes.remote_boefjes_runner import RemoteBoefjesRunner
from boefjes.runtime_interfaces import BoefjeJobRunner, Handler, NormalizerJobRunner
from octopoes.api.models import Affirmation, Declaration, Observation
from octopoes.connector.octopoes import OctopoesAPIConnector
Expand Down Expand Up @@ -102,9 +103,23 @@ def __init__(

def handle(self, boefje_meta: BoefjeMeta) -> None:
logger.info("Handling boefje %s[task_id=%s]", boefje_meta.boefje.id, str(boefje_meta.id))
boefje_resource = self.local_repository.by_id(boefje_meta.boefje.id)

env_keys = boefje_resource.environment_keys
boefje_meta.environment = get_environment_settings(boefje_meta, env_keys) if env_keys else {}

# Check if the user has provided the boefje with a `remote_url`, if so, use the `RemoteBoefjesRunner`
if boefje_meta.environment and boefje_meta.environment.get("REMOTE_URL", ""):
logger.info(
"Forwarding boefje %s[task_id=%s] to a remote container",
boefje_meta.boefje.id,
str(boefje_meta.id),
)
remote_runner = RemoteBoefjesRunner(boefje_resource, boefje_meta)

return remote_runner.run()

# Check if this boefje is container-native, if so, continue using the Docker boefjes runner
boefje_resource = self.local_repository.by_id(boefje_meta.boefje.id)
if boefje_resource.oci_image:
logger.info(
"Delegating boefje %s[task_id=%s] to Docker runner with OCI image [%s]",
Expand All @@ -113,7 +128,7 @@ def handle(self, boefje_meta: BoefjeMeta) -> None:
boefje_resource.oci_image,
)
docker_runner = DockerBoefjesRunner(boefje_resource, boefje_meta)
return docker_runner.run()
return docker_runner.run() # TODO: Make docker use the now-provided environment variables

if boefje_meta.input_ooi:
reference = Reference.from_str(boefje_meta.input_ooi)
Expand All @@ -126,10 +141,7 @@ def handle(self, boefje_meta: BoefjeMeta) -> None:

boefje_meta.arguments["input"] = serialize_ooi(ooi)

env_keys = boefje_resource.environment_keys

boefje_meta.runnable_hash = boefje_resource.runnable_hash
boefje_meta.environment = get_environment_settings(boefje_meta, env_keys) if env_keys else {}

mime_types = _default_mime_types(boefje_meta.boefje)

Expand Down
2 changes: 2 additions & 0 deletions boefjes/boefjes/job_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ class Job(BaseModel):
id: UUID
started_at: AwareDatetime | None = Field(default=None)
ended_at: AwareDatetime | None = Field(default=None)
remote: bool = Field(default=False)

@property
def runtime(self) -> timedelta | None:
Expand All @@ -33,6 +34,7 @@ class Boefje(BaseModel):

id: Annotated[str, StringConstraints(min_length=1)]
version: str | None = Field(default=None)
remote: bool = Field(default=False)


class Normalizer(BaseModel):
Expand Down
1 change: 1 addition & 0 deletions boefjes/boefjes/katalogus/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ class Plugin(BaseModel):
environment_keys: list[str] = Field(default_factory=list)
related: list[str] | None = None
enabled: bool = False
remote: bool = False
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Its not the boefje that is 'remote', its the job in a specific context (eg, on a given IP, or connected to a specific network) that needs/can be ran remotely.

Jobs for Nmap-tcp that are scheduled for an IP in a network|Soufyan-home should not be returned to the job-runner who's limitations only mention Network|Internet, they should be returned to the boefje-runner that asks for jobs with the filter: network|Soufyan-home

N.B. This scoping on networks still needs to be build in the scheduler.

A simpler limitation/job filter could be the following:
The Nmap-TCP boefje can create two types of jobs for Internet connected IP's, one for ipv4 and one for ipv6 input objects.
To be able to succesfully run all Nmap-TCP jobs, the boefje runner needs to be able to access ipv4 and ipv6 'internet'.
I can see a way forward where there's two specific NMAP-TCP boefjes configured, one for each protocol. The Boefje manifest then lists the following needed Traits: ipv4-connectivity, ipv6-connectivity.
The boefje-runner can then from its config file read that it can only reach ipv4 networks, it can reach the network Internet and produces a job-query for the scheduler to receive only jobs that have the ipv4-connectivity trait.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Its not the boefje that is 'remote', its the job in a specific context (eg, on a given IP, or connected to a specific network) that needs/can be ran remotely.

This has been implemented this way because of the fact that tasks currently created with information from the boefje. (so my idea was to create boefjes that state that all tasks created for this type of boefje will have to be ran remotely).

they should be returned to the boefje-runner that asks for jobs with the filter: network|Soufyan-home

Do you have an idea when these tasks should be created? Would the user manually have to create these tasks and mention in what network they should run?

To be able to succesfully run all Nmap-TCP jobs, the boefje runner needs to be able to access ipv4 and ipv6 'internet'.
I can see a way forward where there's two specific NMAP-TCP boefjes configured, one for each protocol.

Do you want OpenKAT give the option to run the same boefje multiple times (with each their own settings)?

The boefje-runner can then from its config file read that it can only reach ipv4 networks, it can reach the network Internet and produces a job-query for the scheduler to receive only jobs that have the ipv4-connectivity trait.

I don't understand what you mean with a boefje-runner only being able to reach ipv4 networks. Shouldn't the boefje decide what kind of IPAddress it can take in?


Right now I have only focused on implementing a "remote" boefje that can request a job whenever that boefje is ready from the outside.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Its not the boefje that is 'remote', its the job in a specific context (eg, on a given IP, or connected to a specific network) that needs/can be ran remotely.

This has been implemented this way because of the fact that tasks currently created with information from the boefje. (so my idea was to create boefjes that state that all tasks created for this type of boefje will have to be ran remotely).

The way you implemented the Remote trait now, can work, but I would add a more general traits system to the boefje config and allow filtering on any/all of them.

they should be returned to the boefje-runner that asks for jobs with the filter: network|Soufyan-home

Do you have an idea when these tasks should be created? Would the user manually have to create these tasks and mention in what network they should run?

Ideally the scheduler just makes jobs, one for each boefje+input-ooi combination. Where they are executed is not up to the user. The user can add their own jobs, but again they won't be able to steer which runner runs these jobs.
All boefjes runners wlll try to execute jobs from the queue as created by the scheduler, but since not all jobs can be excecuted from everywhere, we need to make sure we have enough logic in the scheduler to give the correct jobs to the correct runner based on what networks/hosts they can reach (scopes), and what traits they have (ipv4, ipv6)

To be able to succesfully run all Nmap-TCP jobs, the boefje runner needs to be able to access ipv4 and ipv6 'internet'.
I can see a way forward where there's two specific NMAP-TCP boefjes configured, one for each protocol.

Do you want OpenKAT give the option to run the same boefje multiple times (with each their own settings)?

The boefje-runner can then from its config file read that it can only reach ipv4 networks, it can reach the network Internet and produces a job-query for the scheduler to receive only jobs that have the ipv4-connectivity trait.

I don't understand what you mean with a boefje-runner only being able to reach ipv4 networks. Shouldn't the boefje decide what kind of IPAddress it can take in?

A boefje like Nmap can consume all IP-addresses and should list both types as valid input-oois. However, if the machine running that specific boefje does not have ipv6 connectivity, running Nmap on an ipv6 address just spits out errors. The same would be true for trying to download an ipv6 version of a website.

Right now I have only focused on implementing a "remote" boefje that can request a job whenever that boefje is ready from the outside.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am starting to understand it better, thank you 👍.

I will try to find a way of adding flags to tasks which can be filtered by the boefje runners.

It is still unclear how these tasks should be created with these flags. Should the nmap boefje specify that it will potentially run with IPv6 addresses or should the nmap boefje see that it's runner is not able to run with IPv6 addresses and skip those OOIs?


def __str__(self):
return f"{self.id}:{self.version}"
Expand Down
1 change: 1 addition & 0 deletions boefjes/boefjes/local.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
from octopoes.models import OOI, DeclaredScanProfile

logger = logging.getLogger(__name__)
# TODO: SOUF change filename to `local_boefjes_runner` for consistency


class TemporaryEnvironment:
Expand Down
Empty file.
12 changes: 12 additions & 0 deletions boefjes/boefjes/plugins/kat_remote_scanner/boefje.Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
FROM python:3.11-slim

WORKDIR /app
RUN apt-get update && apt-get install -y nmap && pip install httpx

ARG BOEFJE_PATH=./boefjes/plugins/kat_remote_scanner
ENV PYTHONPATH=/app:$BOEFJE_PATH

COPY ./images/oci_adapter.py ./
COPY $BOEFJE_PATH $BOEFJE_PATH

ENTRYPOINT ["/usr/local/bin/python", "-m", "oci_adapter"]
17 changes: 17 additions & 0 deletions boefjes/boefjes/plugins/kat_remote_scanner/boefje.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
{
"id": "remote-scanner",
"name": "Remote scanner",
"description": "Scans from the remote",
"consumes": [
"HTTPResource",
"IPAddressV4",
"IPAddressV6"
],
"environment_keys": [
"REMOTE_URL",
"COMMAND"
],
"scan_level": 4,
"oci_image": "openkat/remote-scanner",
"remote": true
}
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
3 changes: 3 additions & 0 deletions boefjes/boefjes/plugins/kat_remote_scanner/description.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# REMOTE SCANNER

WHERE DO I GET DISPLAYED
16 changes: 16 additions & 0 deletions boefjes/boefjes/plugins/kat_remote_scanner/main.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import os


def run(boefje_meta: dict):
cmd = os.getenv("COMMAND", "echo no command given")
return [(set(), f"{cmd} has been ran!")]


"""echo $PWD
from ipaddress import ip_address
import socket
hostname = socket.gethostname()
IPAddr = socket.gethostbyname(hostname)
ip = ip_address(boefje_meta["arguments"]["website"]["ip_service"]["ip_port"]["address"]["address"])
return [(set(), f"{hostname};;{IPAddr};;{ip.exploded}")]
"""
17 changes: 17 additions & 0 deletions boefjes/boefjes/plugins/kat_remote_scanner/schema.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
{
"title": "Arguments",
"type": "object",
"properties": {
"COMMAND": {
"title": "COMMAND",
"type": "string",
"description": "The command to execute remotely"
},
"REMOTE_URL": {
"title": "REMOTE_URL",
"type": "string",
"description": "The command location to execute the command"
}
},
"required": []
}
87 changes: 87 additions & 0 deletions boefjes/boefjes/remote_boefjes_runner.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import logging
from datetime import datetime, timezone

import httpx
from httpx import Timeout
from pydantic import BaseModel

from boefjes.clients.bytes_client import BytesAPIClient
from boefjes.clients.scheduler_client import SchedulerAPIClient, TaskStatus
from boefjes.config import settings
from boefjes.job_models import BoefjeMeta
from boefjes.katalogus.models import Boefje

logger = logging.getLogger(__name__)


class RemoteBoefjesRunner:
def __init__(self, boefje_resource: Boefje, boefje_meta: BoefjeMeta):
self.boefje_resource = boefje_resource
self.boefje_meta = boefje_meta
self.scheduler_client = SchedulerAPIClient(str(settings.scheduler_api))
self.bytes_api_client = BytesAPIClient(
str(settings.bytes_api),
username=settings.bytes_username,
password=settings.bytes_password,
)

def run(self) -> None:
REMOTE_URL = self.boefje_meta.environment.get("REMOTE_URL", "")
if not REMOTE_URL:
raise RuntimeError("Boefje does not have a URL")

# local import to prevent circular dependency
import boefjes.plugins.models # TODO: ask why this is needed since boefjes.plugins.models gets imported on top

stderr_mime_types = boefjes.plugins.models._default_mime_types(self.boefje_meta.boefje)

task_id = self.boefje_meta.id
self.scheduler_client.patch_task(task_id, TaskStatus.RUNNING)
self.boefje_meta.started_at = datetime.now(timezone.utc)

try:
task_url = str(settings.api).rstrip("/") + f"/api/v0/tasks/{task_id}"

request = RemoteBoefjeRequest(
name="kat_boefje_" + str(task_id),
task_url=task_url,
boefje_resource=self.boefje_resource,
boefje_meta=self.boefje_meta,
)

logger.info(request.model_dump_json())
response = httpx.post(url=REMOTE_URL, data=request.model_dump_json(), timeout=Timeout(timeout=10))

if response.is_error:
logger.exception(response)

self.boefje_meta.ended_at = datetime.now(timezone.utc)
self.bytes_api_client.save_boefje_meta(self.boefje_meta) # The task didn't create a boefje_meta object
self.bytes_api_client.save_raw(task_id, response, stderr_mime_types.union({"error/boefje"}))
self.scheduler_client.patch_task(task_id, TaskStatus.FAILED)

# have to raise exception to prevent _start_working function from setting status to completed
raise RuntimeError("Boefje did not call output API endpoint")

except httpx.NetworkError as e:
logger.exception("Container error")
logger.exception(request.model_dump_json())
# save container log (stderr) to bytes
self.bytes_api_client.login()
self.boefje_meta.ended_at = datetime.now(timezone.utc)
try:
# this boefje_meta might be incomplete, it comes from the scheduler instead of the Boefje I/O API
self.bytes_api_client.save_boefje_meta(self.boefje_meta)
except httpx.HTTPError:
logger.error("Failed to save boefje meta to bytes, continuing anyway")
self.bytes_api_client.save_raw(task_id, "TODO", stderr_mime_types) # TODO
self.scheduler_client.patch_task(task_id, TaskStatus.FAILED)
# have to raise exception to prevent _start_working function from setting status to completed
raise e


class RemoteBoefjeRequest(BaseModel):
name: str
task_url: str
boefje_resource: Boefje
boefje_meta: BoefjeMeta
4 changes: 3 additions & 1 deletion boefjes/tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,11 +45,12 @@ def get_queues(self) -> list[Queue]:
time.sleep(self.sleep_time)
return TypeAdapter(list[Queue]).validate_json(self.queue_response)

def pop_item(self, queue: str) -> QueuePrioritizedItem | None:
def pop_non_remote_item(self, queue: str) -> QueuePrioritizedItem | None:
time.sleep(self.sleep_time)

try:
if WorkerManager.Queue.BOEFJES.value in queue:
print(self.boefje_responses[0].decode())
p_item = TypeAdapter(QueuePrioritizedItem).validate_json(self.boefje_responses.pop(0))
self._popped_items[str(p_item.id)] = p_item
self._tasks[str(p_item.id)] = self._task_from_id(p_item.id)
Expand Down Expand Up @@ -86,6 +87,7 @@ def _task_from_id(self, task_id: UUID):
status=TaskStatus.DISPATCHED,
created_at=datetime.now(timezone.utc),
modified_at=datetime.now(timezone.utc),
remote=False,
)

def push_item(self, queue_id: str, p_item: QueuePrioritizedItem) -> None:
Expand Down
9 changes: 6 additions & 3 deletions boefjes/tests/examples/scheduler/pop_response_boefje.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,17 @@
"id": "70da7d4f-f41f-4940-901b-d98a92e9014b",
"boefje": {
"id": "dns-records",
"version": null
"version": null,
"remote": false
},
"input_ooi": "Hostname|internet|test.test",
"organization": "_dev",
"arguments": {},
"started_at": null,
"runnable_hash": null,
"environment": null,
"ended_at": null
}
"ended_at": null,
"remote": false
},
"remote": false
}
Loading