diff --git a/.ansible-lint.yml b/.ansible-lint.yml new file mode 100644 index 0000000..e51016f --- /dev/null +++ b/.ansible-lint.yml @@ -0,0 +1,6 @@ +--- + +skip_list: + - var-naming + - ignore-errors + - galaxy[no-changelog] diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 0000000..5f2aff0 --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,50 @@ +--- + +name: Lint + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + build: + strategy: + matrix: + python-version: [3.10] + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v3 + with: + ref: ${{ github.ref }} + + - name: Install python + uses: actions/setup-python@v4 + with: + python-version: '3.10' + + - name: Install dependencies + run: | + pip install -r requirements_lint.txt + shell: bash + + - name: Running PyLint + run: pylint --recursive=y . + shell: bash + + - name: Running YamlLint + run: yamllint . + shell: bash + + - name: Preparing collection for AnsibleLint + run: | + mkdir -p /tmp/ansible_lint/collections/ansible_collections/ansibleguy + ln -s ${{ github.workspace }} /tmp/ansible_lint/collections/ansible_collections/ansibleguy/opnsense + shell: bash + + - name: Running AnsibleLint + run: ANSIBLE_COLLECTIONS_PATH=/tmp/ansible_lint/collections ansible-lint -c .ansible-lint.yml + shell: bash diff --git a/.pylintrc b/.pylintrc new file mode 100644 index 0000000..40151a7 --- /dev/null +++ b/.pylintrc @@ -0,0 +1,638 @@ +[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=10 + +# 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=CVS + +# 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 +# (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 + +# Minimum Python version to use for version dependent checks. Will default to +# the version used to run pylint. +py-version=3.10 + +# 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= + + +[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=8 + +# 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=18 + +# 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 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=160 + +# Maximum number of lines in a module. +max-module-lines=1000 + +# 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=old + +# 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=raw-checker-failed, + bad-inline-option, + locally-disabled, + file-ignored, + suppressed-message, + useless-suppression, + deprecated-pragma, + use-symbolic-message-instead, + C0114, C0115, C0116, # docstrings + C0411, C0412, C0413, # import sequence + R1735, # {} instead of dict() + C0103, # var-naming + W0511, # todo + W0707, # re-raise exception + E0611, E0401, # unable to import + +# 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=c-extension-no-member + + +[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 + + +[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, json +# 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/.yamllint b/.yamllint new file mode 100644 index 0000000..225dbba --- /dev/null +++ b/.yamllint @@ -0,0 +1,11 @@ +--- + +# see: https://yamllint.readthedocs.io/en/latest/rules.html + +extends: default + +rules: + truthy: + allowed-values: ['true', 'false', 'yes', 'no', 'True', 'False'] + line-length: + max: 180 diff --git a/README.md b/README.md new file mode 100644 index 0000000..03c7f8a --- /dev/null +++ b/README.md @@ -0,0 +1,57 @@ +# Ansible Modules - ASCIO + +The domain registrar [ASCIO](https://www.ascio.com/) allows one to manage domains using their [APIs](https://aws.ascio.info/api-v3/php5/domains-introduction.html). + +To automate the registration/update process we implemented the most important API endpoints as Ansible modules: + +* [Get domain info](https://aws.ascio.info/api-v3/python/getdomains) +* [Register domains](https://aws.ascio.info/api-v3/python/createorder-register-domain) +* [Update domain information](https://aws.ascio.info/api-v3/python/createorder-domain-details-update) + +---- + +## Install + +``` +# install the requirements on your controller +python3 -m pip install -r requirements.txt + +# install the collection +ansible-galaxy install niceshopsOrg.ascio +# OR +ansible-galaxy install git+https://github.com/niceshops/ansible-module-ascio.git +``` + +To allow connections using the API you need to add your **source Public-IPs to the allow-list** that you can find in your ASCIO account settings! + +---- + +## Usage + +### Get Domain information + +Check out the [example playbook](https://github.com/niceshops/ansible-module-ascio/blob/main/playbook_get.yml)! + +### Register Domain + +#### TLD Config + +The main challenge when configuring the domain config is that there are different requirements for some TLD's. + +You can check the requirements a TLD using the **ASCIO TLDKit**: + +* Open the URL `https://tldkit.ascio.com/api/v1/Tldkit/` (*replace the leading ''*) +* Log-in with your ASCIO credentials + +You will have to either: + +* hard-code the contact-information for every domain +* implement an automated logic to merge & modify your default contacts to fit every TLD (*that's how we do it*) + +As an example on how a raw TLD-Config could look like - see: [example config](https://github.com/niceshops/ansible-module-ascio/blob/main/tld_config.json) + +#### Run + +Check out the [example playbook](https://github.com/niceshops/ansible-module-ascio/blob/main/playbook_register.yml)! + +Each query is limited to 1000 domains! If you have more than that you will have to go through multiple 'pages' (*multiple runs*) diff --git a/galaxy.yml b/galaxy.yml new file mode 100644 index 0000000..75a4beb --- /dev/null +++ b/galaxy.yml @@ -0,0 +1,20 @@ +--- + +namespace: 'niceshopsorg' +name: 'ascio' +version: 1.0.0 +readme: 'README.md' +authors: + - 'Rene Rath ' +description: "Ansible Module to manage Domains on ASCIO" +license_file: 'LICENSE' +tags: + - 'domain' + - 'domains' + - 'dns' +dependencies: {} +repository: 'https://github.com/niceshops/ansible-module-ascio' +documentation: 'https://github.com/niceshops/ansible-module-ascio/README.md' +homepage: 'https://www.niceshops.com' +issues: 'https://github.com/niceshops/ansible-module-ascio/issues' +build_ignore: [] diff --git a/meta/runtime.yml b/meta/runtime.yml new file mode 100644 index 0000000..9e2861a --- /dev/null +++ b/meta/runtime.yml @@ -0,0 +1,3 @@ +--- + +requires_ansible: ">=2.14" diff --git a/playbook_get.yml b/playbook_get.yml new file mode 100644 index 0000000..acd3abe --- /dev/null +++ b/playbook_get.yml @@ -0,0 +1,73 @@ +--- + +- name: ASCIO Get Information + hosts: localhost + vars_prompt: + - name: api_user + prompt: Provide the api user (same as for the portal.ascio.com login) + private: no + - name: api_pwd + prompt: Provide the api password + private: yes + - name: filter_names + prompt: Provide domains to search (comma separated) + private: no + default: '' + - name: filter_tld_list + prompt: Provide a comma-separated list of tlds to filter on + private: no + default: '' + - name: output_console + prompt: Output result to console? + private: no + default: 'yes' + - name: output_file + prompt: Output result to file? (/tmp/ascio_get_${DATETIME}.json & /tmp/ascio_get_${DATETIME}.csv) + private: no + default: 'yes' + - name: ascio_get_page + prompt: Get another 'page' via API? (limited to 1000 results..) + private: no + default: "1" + + tasks: + - name: ASCIO | Get domain details if we registered it + niceshopsOrg.ascio.get: + user: "{{ api_user }}" + password: "{{ api_pwd }}" + filter_names: "{{ filter_names.split(',') }}" + filter_tld: "{{ filter_tld }}" + # filter_type: 'Premium' + # filter_comment: 'Test' + # filter_status: "{{ filter_status | default('All') }}" + # filter_expire_from: '2021-10-12T13:08:56.956+02:00' + # filter_expire_to: '2021-10-12T16:08:56.956+02:00' + # results: 5000 + results_page: "{{ ascio_get_page }}" + register: result + delegate_to: localhost + ignore_errors: true + + - name: ASCIO | Output to Console + ansible.builtin.debug: + msg: [ + "filter domains: {{ filter_names.split(',') }}", + "filter tld's: {{ filter_tld }}", + "{{ result }}" + ] + when: output_console | bool + ignore_errors: true + + - name: ASCIO | Writing output to json-file + ansible.builtin.copy: + content: "{{ result | filter_results | to_json }}" + dest: "/tmp/ascio_get_{{ filter_tld }}_{{ ansible_date_time.iso8601_basic }}.json" + mode: 0640 + when: output_file | bool + ignore_errors: true + + - name: ASCIO | Writing output to csv-file + ansible.builtin.debug: + msg: "{{ result | filter_results | write_domain_csv('/tmp/ascio_get_' + filter_tld + '_' + ansible_date_time.iso8601_basic + '.csv') }}" + when: output_file | bool + ignore_errors: true diff --git a/playbook_register.yml b/playbook_register.yml new file mode 100644 index 0000000..b3c31ed --- /dev/null +++ b/playbook_register.yml @@ -0,0 +1,82 @@ +--- + +- name: ASCIO Register + hosts: localhost + vars_prompt: + - name: api_user + prompt: Provide the api user (same as for the portal.ascio.com login) + private: no + - name: api_pwd + prompt: Provide the api password + private: yes + - name: force + prompt: Do you want to force the processing? (documentation required) [yes/NO] + private: no + default: 'no' + - name: register_premium + prompt: Do you want to enable registration of premium domains? (costly) [yes/NO] + private: no + default: 'no' + - name: register_max_price + prompt: Set your max price for domain registration! Default=100€ + private: no + default: '100' + + vars: + domain: + name: 'example.org' + nameservers: + - 'ns-cloud-d1.googledomains.com.' + - 'ns-cloud-d2.googledomains.com.' + - 'ns-cloud-d3.googledomains.com.' + - 'ns-cloud-d4.googledomains.com.' + local_presence: false + + domain_registrant: + owner: {} + tech: {} + admin: {} + billing: {} + + tasks: + - name: ASCIO | Register/Update the domain + niceshopsOrg.ascio.register: + user: "{{ api_user }}" + password: "{{ api_pwd }}" + domain: "{{ domain.name }}" + nameservers: "{{ domain.nameservers }}" + contact_owner: "{{ domain_registrant.owner }}" + contact_tech: "{{ domain_registrant.tech }}" + contact_admin: "{{ domain_registrant.admin }}" + contact_billing: "{{ domain_registrant.billing }}" + premium: "{{ register_premium | bool }}" + max_price: "{{ register_max_price }}" + # update_only_ns: false + force: "{{ force | default(ascio_force) }}" + lp: "{{ domain.local_presence }}" + register: result + ignore_errors: true + + - name: ASCIO | Changed result + ansible.builtin.debug: + msg: "Result: {{ result.msg }}, + Owned: {{ result.owner }}, + Available: {{ result.available }}, + Price: {{ result.price }} {{ result.price_currency }}, + Premium: {{ result.premium }}" + when: + - not result.failed | default(false) + - result.changed | default(false) + + - name: ASCIO | Order + ansible.builtin.debug: + msg: "{% if 'order' in result and result.order is not none and 'OrderId' in result['order'] %}OrderId: {{ result.order['OrderId'] }}{% else %}None{% endif %}" + when: + - result.failed is undefined or not result.failed + - result.changed | default(false) + - not ansible_check_mode + + - name: ASCIO | Error result + ansible.builtin.debug: + msg: "Result: {{ result.errors }}" + when: result.failed | default(false) diff --git a/plugins/module_utils/api_base.py b/plugins/module_utils/api_base.py new file mode 100644 index 0000000..e623ac7 --- /dev/null +++ b/plugins/module_utils/api_base.py @@ -0,0 +1,44 @@ +from zeep import xsd, Client, Settings +from zeep.helpers import serialize_object as serialize_zeep_object +from json import dumps as json_dumps +from json import loads as json_loads +from datetime import datetime + +DEBUG_LOG = True +DEBUG_LOG_FILE = '/tmp/ascio_api_request.log' + + +def ascio_api(method: str, user: str, password: str, request: dict, request_type: str = None) -> dict: + # abstraction function since this basic construct is used for all ascio APIv3 calls + wsdl = "https://aws.ascio.com/v3/aws.wsdl" + settings = Settings(strict=False) + client = Client(wsdl=wsdl, settings=settings) + + client.set_ns_prefix('v3', 'http://www.ascio.com/2013/02') + header = xsd.Element( + '{http://www.ascio.com/2013/02}SecurityHeaderDetails', + xsd.ComplexType([ + xsd.Element( + '{http://www.ascio.com/2013/02}Account', + xsd.String()), + xsd.Element( + '{http://www.ascio.com/2013/02}Password', + xsd.String()) + ]) + ) + header_value = header( + Account=user, + Password=password, + ) + if request_type is not None: + request_type = client.get_type(request_type) + request = request_type(**request) + + if DEBUG_LOG: + with open(DEBUG_LOG_FILE, 'a+', encoding='utf-8') as log: + log.write(f"{datetime.now().strftime('%Y-%m-%d %H:%M:%S')} - Request for method '{method}': '{request}'\n\n") + + _method = getattr(client.service, method) + response = _method(_soapheaders=[header_value], request=request) + response_dict = serialize_zeep_object(response, dict) + return json_loads(json_dumps(response_dict, default=str)) # json dump/load used to get rid of unsupported data-types diff --git a/plugins/module_utils/api_get_domains.py b/plugins/module_utils/api_get_domains.py new file mode 100644 index 0000000..e1b50f7 --- /dev/null +++ b/plugins/module_utils/api_get_domains.py @@ -0,0 +1,73 @@ +from ansible_collections.niceshopsorg.ascio.plugins.module_utils.api_base import ascio_api +from ansible_collections.niceshopsorg.ascio.plugins.module_utils import config as api_config + +from sys import exc_info as sys_exc_info +from traceback import format_exc + +# for api see: +# https://aws.ascio.info/api-v3/python/getdomains +# https://aws.ascio.info/api-v3/python/schema/GetDomainsResponse + +# added as utils since this function is used in multiple modules + + +def ascio_get_domains(params: dict) -> dict: + # overwriting default parameters with custom supplied ones + _parameters = api_config.GET_DOMAINS_DEFAULTS.copy() + _parameters.update(params) + + try: + response = ascio_api( + method='GetDomains', + user=_parameters['user'], + password=_parameters['password'], + request={ + "OrderSort": _parameters['order_by'], + "Status": _parameters['filter_status'], + "Tlds": {"string": _parameters['filter_tld']}, + "ObjectNames": {"string": _parameters['filter_names']}, + "DomainType": _parameters['filter_type'], + "DomainComment": _parameters['filter_comment'], + "ExpireFromDate": _parameters['filter_expire_from'], + "ExpireToDate": _parameters['filter_expire_to'], + "PageInfo": { + "PageIndex": _parameters['results_page'], + "PageSize": _parameters['results'], + }, + # "Handles": {"string": [""]}, + # "CreationFromDate": "2021-10-12T13:08:56.956+02:00", + # "CreationToDate": "2021-10-12T13:08:56.956+02:00", + # "OwnerName": "OwnerNameTest", + # "OwnerOrganizationName": "OwnerOrganizationNameTest", + # "OwnerEmail": "OwnerEmailTest", + # "ContactFirstName": "ContactFirstNameTest", + # "ContactLastName": "ContactLastNameTest", + # "ContactOrganizationName": "ContactOrganizationNameTest", + # "ContactEmail": "ContactEmailTest", + # "NameServerHostName": "NameServerHostNameTest", + # "NameServerIPv4": "NameServerIPv4Test", + # "NameServerIPv6": "NameServerIPv6Test", + # "CustomerReferenceExternalId": "CustomerReferenceExternalIdTest", + # "CustomerReferenceDescription": "CustomerReferenceDescriptionTest", + } + ) + # todo: remove useless stuff from 'data' => what do we want to do with that data? + + return { + 'DomainInfos': response['DomainInfos'], + 'TotalCount': response['TotalCount'], + 'Errors': response['Errors'], + 'ResultCode': response['ResultCode'], + 'ResultMessage': response['ResultMessage'], + } + + # pylint: disable=W0718 + except Exception as error: + exc_type, _, _ = sys_exc_info() + return { + 'DomainInfos': {}, + 'TotalCount': 0, + 'Errors': {'string': [str(exc_type), str(error), str(format_exc())]}, + 'ResultCode': 0, + 'ResultMessage': None, + } diff --git a/plugins/module_utils/config.py b/plugins/module_utils/config.py new file mode 100644 index 0000000..ed43b5e --- /dev/null +++ b/plugins/module_utils/config.py @@ -0,0 +1,17 @@ +# for hardcoded config parameters +RESULT_CODE_SUCCESS = [200, 201] +DOMAIN_TYPE_STANDARD = 'Standard' +DOMAIN_AVAILABLE_RESULT = 'Available' +GET_DOMAINS_DEFAULTS = { + 'order_by': 'CreatedAsc', + 'filter_status': 'All', + 'filter_tld': [], + 'filter_names': [], + 'filter_type': None, + 'filter_comment': None, + 'filter_expire_from': None, + 'filter_expire_to': None, + 'results_page': 1, + 'results': 1000, +} +WHOIS_GDPR_TLDs = ['com', 'net', 'cc', 'tv'] # see: https://aws.ascio.info/gdpr-api.html diff --git a/plugins/module_utils/tldkit.py b/plugins/module_utils/tldkit.py new file mode 100644 index 0000000..83144af --- /dev/null +++ b/plugins/module_utils/tldkit.py @@ -0,0 +1,105 @@ +from requests import get +from json import dumps as json_dumps +from json import loads as json_loads +from datetime import datetime +from os import path, mkdir + +TLDKIT_BASE_URL = 'https://tldkit.ascio.com/api/v1/Tldkit' +CACHE_DIR = '~/.cache/ansible-module-ascio' +MAX_CACHE_AGE = 180 + + +class TLD: + def __init__(self, user: str, password: str, domain: str, action: str = '', tld_cache: str = CACHE_DIR): + self.user = user + self.password = password + self.tld = domain.rsplit('.', 1)[1] + self.action = action.upper() + # REGISTER, DELETE, CONTACT UPDATE, NAMESERVER UPDATE, OWNER CHANGE, RENEW, TRANSFER, AUTORENEW, RESTORE + # EXPIRE, REGISTRANT DETAILS UPDATE, TRANSFER AWAY + self.cache_dir = tld_cache + self.cache_file = f'{tld_cache}/{self.tld}.json' + + def docs_required(self) -> bool: + req = self._get_action_attribute(attribute='DocumentationRequired') + return False if req is None else req + + def contacts_permitted(self) -> bool: + bad_list = ['not permitted', 'not supported', 'Contact roles does not exist'] + update_todo = self._get_action_attribute(attribute='Procedure', action='CONTACT UPDATE') + permitted = True + + if update_todo is not None: + for bad in bad_list: + if update_todo.find(bad) != -1: + permitted = False + break + + return permitted + + def _get_info_online(self) -> dict: + return get( + f"{TLDKIT_BASE_URL}/{self.tld}", auth=(self.user, self.password), + timeout=90, + ).json() + + def _cache_valid(self) -> bool: + if path.exists(self.cache_file): + cache_update_time = datetime.fromtimestamp(path.getmtime(self.cache_file)) + cache_age = datetime.now() - cache_update_time + + if cache_age.days > MAX_CACHE_AGE: + return False + + return True + + return False + + def _cache_write(self, data: dict) -> bool: + with open(self.cache_file, 'w', encoding='utf-8') as cache: + cache.write(json_dumps(data)) + return True + + def _cache_read(self) -> dict: + with open(self.cache_file, 'r', encoding='utf-8') as cache: + return json_loads(cache.read()) + + def _get_info(self) -> dict: + if self._cache_valid(): + return self._cache_read() + + data = self._get_info_online() + + if not path.exists(self.cache_dir): + mkdir(self.cache_dir) + + self._cache_write(data=data) + return data + + def _get_action_attribute(self, attribute: str, action: str = None): + if action is None: + action = self.action + + for process in self._get_info()['Processes']: + if process['Command'] == action: + return process[attribute] + + return None + + def lp_needed(self): + # not used since it is set to 'false' on some domains that require a LP.. don't know why that is + return self._get_info()['LocalPresenceRequired'] + + def lp_offered(self): + return self._get_info()['LocalPresenceOffered'] + + +if __name__ == '__main__': + result = TLD( + user=input('Provide the ASCIO API-User:\n > '), + password=input('Provide the ASCIO API-Password:\n > '), + domain=input('Provide the domain to check:\n > '), + action=input('Provide the action:\n > '), + ).docs_required() + + print(f"\nResult: {result}") diff --git a/plugins/modules/get.py b/plugins/modules/get.py new file mode 100644 index 0000000..1684f6f --- /dev/null +++ b/plugins/modules/get.py @@ -0,0 +1,150 @@ +#!/usr/bin/python + +# Copyright: (c) 2021, Rene Rath +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) +from __future__ import (absolute_import, division, print_function) +__metaclass__ = type +from ansible.module_utils.basic import AnsibleModule +from ansible_collections.niceshopsorg.ascio.plugins.module_utils.api_get_domains import ascio_get_domains +from ansible_collections.niceshopsorg.ascio.plugins.module_utils import config as api_config + +# see: https://docs.ansible.com/ansible/latest/dev_guide/developing_program_flow_modules.html#ansiblemodule +# for api see: +# https://aws.ascio.info/api-v3/python/getdomains +# https://aws.ascio.info/api-v3/python/schema/GetDomainsResponse + +DOCUMENTATION = "https://github.com/niceshops/ansible-module-ascio" +EXAMPLES = "https://github.com/niceshops/ansible-module-ascio" +RETURN = "https://github.com/niceshops/ansible-module-ascio" + + +def nice_check(module: AnsibleModule, params: dict = None) -> dict: + # params var can be used to import this function from other modules + if params is None and AnsibleModule is not None: + params = module.params + + elif params is None: + return {} + + # run 'check-mode' tasks to find out if the state has changed + failed = False + + # get data from existing item and build its dataset for comparison + response = ascio_get_domains(params=params) + + # fail if we were not able to retrieve the data + if response['ResultCode'] not in api_config.RESULT_CODE_SUCCESS or len(response['Errors']['string']) > 0: + failed = True + + return { + 'failed': failed, + 'data': response['DomainInfos'], + 'count': response['TotalCount'], + 'errors': response['Errors']['string'], + } + + +def run_module(): + # arguments we expect + module_args = dict( + user=dict(type='str', required=True), + password=dict(type='str', required=True, no_log=True), + order_by=dict( + type='str', + description='How to sort the response entries', + default=api_config.GET_DOMAINS_DEFAULTS['order_by'], + ), + filter_tld=dict( + type='list', description='TLDs to filter on', + default=api_config.GET_DOMAINS_DEFAULTS['filter_tld'], + ), + filter_names=dict( + type='list', + description='Domain Names to filter on', + default=api_config.GET_DOMAINS_DEFAULTS['filter_names'], + ), + filter_type=dict( + type='str', + description='DomainTypes to filter on', + default=api_config.GET_DOMAINS_DEFAULTS['filter_type'], + choices=['Premium', 'Standard'], + ), + filter_comment=dict( + type='str', + description='Comment to filter on', + default=api_config.GET_DOMAINS_DEFAULTS['filter_comment'], + ), + filter_status=dict( + type='str', + description='Domain Status to filter on', + default=api_config.GET_DOMAINS_DEFAULTS['filter_status'], + choices=[ + 'All', 'All Except Deleted', 'Active', 'Expiring', 'Pending Verification', 'Parked', 'Pending Auction', + 'Queued', 'Lock', 'Transfer Lock', 'Update Lock', 'Delete Lock', 'Deleted' + ] + ), + filter_expire_from=dict( + type='str', + description='Expiration date start to filter on, Format: 2021-10-12T13:08:56.956+02:00', + default=api_config.GET_DOMAINS_DEFAULTS['filter_expire_from'], + ), + filter_expire_to=dict( + type='str', + description='Expiration date stop to filter on, Format: 2021-10-12T15:08:56.956+02:00', + default=api_config.GET_DOMAINS_DEFAULTS['filter_expire_to'], + ), + results=dict( + type='int', + default=api_config.GET_DOMAINS_DEFAULTS['results'], + description='How many results should be returned by the response' + ), + results_page=dict( + type='int', + default=api_config.GET_DOMAINS_DEFAULTS['results_page'], + description="If more entries than 'results' exist => you can change the page" + ), + ) + module = AnsibleModule( + argument_spec=module_args, + supports_check_mode=True, + ) + + # set default results + result = dict( + failed=False, + data=None, + count=0, + errors=[], + ) + + # custom conversion + module.params['filter_names'] = [name.encode('idna').decode('utf-8') for name in module.params['filter_names']] + module.params['filter_tld'] = [tld.encode('idna').decode('utf-8') for tld in module.params['filter_tld']] + + # run check or do actual work + _task_result = nice_check(module=module) + + # return status and changes to user + if _task_result['failed']: + module.fail_json( + msg='The ASCIO-API returned an error!', + result=dict( + errors=_task_result['errors'], + failed=True, + ) + ) + + else: + result['data'] = _task_result['data'] + result['errors'] = _task_result['errors'] + result['count'] = _task_result['count'] + + module.exit_json(**result) + + +def main(): + run_module() + + +if __name__ == '__main__': + main() diff --git a/plugins/modules/register.py b/plugins/modules/register.py new file mode 100644 index 0000000..37700b1 --- /dev/null +++ b/plugins/modules/register.py @@ -0,0 +1,546 @@ +#!/usr/bin/python + +# Copyright: (c) 2021, Rene Rath +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) +from __future__ import (absolute_import, division, print_function) +__metaclass__ = type +from ansible.module_utils.basic import AnsibleModule +from ansible_collections.niceshopsorg.ascio.plugins.module_utils.api_get_domains import ascio_get_domains +from ansible_collections.niceshopsorg.ascio.plugins.module_utils.api_base import ascio_api +from ansible_collections.niceshopsorg.ascio.plugins.module_utils import config as api_config +from ansible_collections.niceshopsorg.ascio.plugins.module_utils.tldkit import TLD + +from sys import exc_info as sys_exc_info +from traceback import format_exc +from re import match as regex_match + +# see: https://docs.ansible.com/ansible/latest/dev_guide/developing_program_flow_modules.html#ansiblemodule + +DOCUMENTATION = "https://github.com/niceshops/ansible-module-ascio" +EXAMPLES = "https://github.com/niceshops/ansible-module-ascio" +RETURN = "https://github.com/niceshops/ansible-module-ascio" + + +class Register: + DIFF_COMPARE_FILTER = { + # we will remove all field received from the get-call if they are not listed here + # else there will always be a difference + 'nameservers': ['HostName'], + 'contacts': [ + 'FirstName', 'LastName', 'OrgName', 'Address1', 'Address2', 'City', 'State', 'PostalCode', 'CountryCode', + 'Phone', 'Email', 'Type', 'Details', 'OrganisationNumber', 'Number', 'VatNumber' + ] + } + OWNER_CHANGE_FIELDS = ['FirstName', 'LastName', 'OrganisationNumber', 'Email'] + MODULE_API_CONTACT_MAPPING = { + 'contact_owner': 'Owner', + 'contact_admin': 'Admin', + 'contact_tech': 'Tech', + 'contact_billing': 'Billing', + } + + HIDE_WHOIS_TLDs = ['com', 'cc', 'tv'] # .net did not work + KNOWN_ERRORS = { + 'pending': 'FO405', + 'update_pending': "Order rejected because of '.*?' order '.*?' on same object", # ..Object status prohibits operation + 'balance_exceeded': 'Partner (.*?) blocked', + } + + TRADEMARK_COUNTRY_TLDs = ['it'] # tld's that need the trademark country to be set (will be the owners country) + + def __init__(self, module: AnsibleModule): + self.module = module + self.nameservers = None + self.result = { + 'failed': False, + 'errors': [], + 'premium': False, + 'price': None, + 'price_currency': None, + 'available': False, + 'msg': False, + 'changed': False, + 'order': None, + 'owner': False, + 'diff': { + 'before': {}, + 'after': {}, + }, + } + + def check(self) -> dict: + # checking if + # we have registered the domain already + # if the domain is available + # if the relevant domain config has been changed + + self.nameservers = self._build_nameservers(ns_list=self.module.params['nameservers']) + + # get existing domains to check if we already registered the requested domain + response = ascio_get_domains( + params={ + 'user': self.module.params['user'], + 'password': self.module.params['password'], + 'filter_names': [self.module.params['domain']], + }, + ) + self.result['msg'] = response['ResultMessage'] + + if response['ResultCode'] not in api_config.RESULT_CODE_SUCCESS or len(response['Errors']['string']) > 0: + # fail if we were not able to retrieve the data + self.result['failed'] = True + self.result['errors'].extend(response['Errors']['string']) + + else: + if response['TotalCount'] == 0: + self.result['changed'] = True + + else: + self.result['owner'] = True + self._compare_config(response=response) + + self._get_availability() + + if self.module.check_mode: + # output infos regarding documentation requirements + if not self.result['owner']: + self._docs_required( + action='REGISTER', + msg='Documentation is required to register this TLD! Execution can be forced.' + ) + + elif self.result['changed']: + self._docs_required( + action='NAMESERVER UPDATE', + msg='Documentation is required to update nameservers for this TLD! Execution can be forced.' + ) + self._docs_required( + action='OWNER CHANGE', + msg='Documentation is required to update the owner for this TLD! Execution can be forced.' + ) + if self._contacts_permitted(): + self._docs_required( + action='CONTACT UPDATE', + msg='Documentation is required to update the contacts for this TLD! Execution can be forced.' + ) + + return self.result + + def set(self) -> dict: + # run 'check-mode' tasks to find out if the state has changed + self.check() + + # run action if check succeeded and action is required + if not self.result['failed'] and self.result['changed']: + if self.module.params['max_price'] is not None and self.result['price'] is not None and \ + self.result['price'] > self.module.params['max_price']: + # if we defined a maximum price and the price is higher + self.result['errors'].append('Domain price was higher than you allowed it to be!') + self.result['failed'] = True + return self.result + + if self.result['premium'] and not self.module.params['premium']: + # if domain is premium and we don't allow registration of premium domains + self.result['errors'].append( + "Domain is listed as 'premium' but you did not allow premium domains to be registered!" + ) + self.result['failed'] = True + return self.result + + if not self.result['available'] and not self.result['owner']: + # if the domain is owned by someone else + self.result['errors'].append("Domain is not available for registration!") + self.result['failed'] = True + return self.result + + # run the actual tasks to register the domain + if not self.result['owner']: + if not self._docs_required( + action='REGISTER', + msg='Documentation is required to register this TLD! Execution can be forced.' + ): + self._create_call() + + else: + self._update_call() + + self._error_check() + return self.result + + def _error_check(self): + # replacing generic error messages with ones that actually have a meaning + new_errors = [] + + for error in self.result['errors']: + if regex_match(f".*{self.KNOWN_ERRORS['pending']}.*", error) is not None: + new_errors.append('Domain is in Status PENDING => no changes can be made!') + + elif regex_match(f".*{self.KNOWN_ERRORS['update_pending']}.*", error) is not None: + new_errors.append( + 'After contact/owner-updates it can take some minutes before another change can be performed!' + ) + + elif regex_match(f".*{self.KNOWN_ERRORS['balance_exceeded']}.*", error) is not None: + new_errors.append( + 'The monthly account-balance has exceeded a maximum threshold! ' + 'You need to transfer some money to ASCIO to unblock your account!' + ) + + else: + new_errors.append(error) + + self.result['errors'] = new_errors + + def _update_call(self): + # update calls + # update nameservers + if self.result['diff']['before']['nameservers'] != self.result['diff']['after']['nameservers']: + if not self._docs_required( + action='NAMESERVER UPDATE', + msg='Documentation is required to update nameservers for this TLD! Execution can be forced.' + ): + response = ascio_api( + method='CreateOrder', + user=self.module.params['user'], + password=self.module.params['password'], + request={ + 'Type': 'NameserverUpdate', + 'Domain': { + 'Name': self.module.params['domain'], + 'NameServers': self.nameservers, + } + }, + request_type='v3:DomainOrderRequest', + ) + + self.result['msg'] = response['ResultMessage'] + self.result['errors'].extend(response['Errors']['string']) + + if response['ResultCode'] not in api_config.RESULT_CODE_SUCCESS: + self.result['failed'] = True + + if not self.module.params['update_only_ns']: + # update contacts + contact_update = False + + if self.result['diff']['before']['contact_billing'] != self.result['diff']['after']['contact_billing'] or \ + self.result['diff']['before']['contact_admin'] != self.result['diff']['after']['contact_admin'] or \ + self.result['diff']['before']['contact_tech'] != self.result['diff']['after']['contact_tech']: + + if not self._docs_required( + action='CONTACT UPDATE', + msg='Documentation is required to update the contacts for this TLD! Execution can be forced.' + ) and self._contacts_permitted(): + contact_update = True + response = ascio_api( + method='CreateOrder', + user=self.module.params['user'], + password=self.module.params['password'], + request={ + 'Type': 'ContactUpdate', + 'Domain': { + 'Name': self.module.params['domain'], + 'Admin': self.module.params['contact_admin'], + 'Tech': self.module.params['contact_tech'], + 'Billing': self.module.params['contact_billing'], + } + }, + request_type='v3:DomainOrderRequest', + ) + + self.result['msg'] = response['ResultMessage'] + self.result['errors'].extend(response['Errors']['string']) + + if response['ResultCode'] not in api_config.RESULT_CODE_SUCCESS: + self.result['failed'] = True + + # update registrant + owner_change = False + owner_details = False + + if self.result['diff']['before']['contact_owner'] != self.result['diff']['after']['contact_owner']: + if contact_update: + self.result['errors'].append( + 'The contacts and owner cannot be changed at the same time => ' + 'you need to run the update again after the current changes have been completed.' + ) + + elif not self._docs_required( + action='OWNER CHANGE', + msg='Documentation is required to update the owner for this TLD! Execution can be forced.' + ): + # first we check if a owner-change is needed + + for field in self.result['diff']['before']['contact_owner']: + if field in self.OWNER_CHANGE_FIELDS and \ + self.result['diff']['before']['contact_owner'][field] != self.result['diff']['after']['contact_owner'][field]: + + owner_change = True + + elif self.result['diff']['before']['contact_owner'][field] != self.result['diff']['after']['contact_owner'][field]: + owner_details = True + + if owner_change: + response = ascio_api( + method='CreateOrder', + user=self.module.params['user'], + password=self.module.params['password'], + request=self._registration_special_cases({ + 'Type': 'OwnerChange', + 'Domain': { + 'Name': self.module.params['domain'], + 'Owner': self.module.params['contact_owner'], + } + }), + request_type='v3:DomainOrderRequest', + ) + + else: + response = ascio_api( + method='CreateOrder', + user=self.module.params['user'], + password=self.module.params['password'], + request=self._registration_special_cases({ + 'Type': 'RegistrantDetailsUpdate', + 'Domain': { + 'Name': self.module.params['domain'], + 'Owner': self.module.params['contact_owner'], + } + }), + request_type='v3:DomainOrderRequest', + ) + + if owner_details and owner_change: + self.result['errors'].append( + 'The owner cannot be changed and updated at the same time => ' + 'you need to run the update again after the current changes have been completed.' + ) + + self.result['msg'] = response['ResultMessage'] + self.result['errors'].extend(response['Errors']['string']) + + if response['ResultCode'] not in api_config.RESULT_CODE_SUCCESS: + self.result['failed'] = True + + def _create_call(self): + # register/create call + request = self._registration_special_cases({ + 'Type': 'Register', + 'Domain': { + 'Name': self.module.params['domain'], + 'Owner': self.module.params['contact_owner'], + 'Admin': self.module.params['contact_admin'], + 'Tech': self.module.params['contact_tech'], + 'Billing': self.module.params['contact_billing'], + 'NameServers': self.nameservers, + } + }) + + response = ascio_api( + method='CreateOrder', + user=self.module.params['user'], + password=self.module.params['password'], + request=request, + request_type='v3:DomainOrderRequest', + ) + + self.result['order'] = response['OrderInfo'] + self.result['msg'] = response['ResultMessage'] + self.result['errors'].extend(response['Errors']['string']) + + if response['ResultCode'] not in api_config.RESULT_CODE_SUCCESS: + self.result['failed'] = True + + def _get_availability(self): + # check availability of domain and its price + response = ascio_api( + method='AvailabilityInfo', + user=self.module.params['user'], + password=self.module.params['password'], + request={ + "DomainName": self.module.params['domain'], + "Quality": "QualityTest", + } + ) + + self.result['msg'] = response['ResultMessage'] + if response['ResultMessage'] == api_config.DOMAIN_AVAILABLE_RESULT: + self.result['available'] = True + + if response['ResultCode'] in api_config.RESULT_CODE_SUCCESS and len(response['Errors']['string']) == 0: + if response['DomainType'] != api_config.DOMAIN_TYPE_STANDARD: + self.result['premium'] = True + + for info in response['Prices']['PriceInfo']: + if info['Product']['OrderType'] == 'Register': + self.result['price'] = float(info['Price']) + + self.result['price_currency'] = response['Currency'] + + else: + self.result['errors'].extend(response['Errors']['string']) + self.result['failed'] = True + + def _registration_special_cases(self, request: dict) -> dict: + _tld = request['Domain']['Name'].rsplit('.', 1)[1] + + if self.module.params['whois_hide'] and _tld in self.HIDE_WHOIS_TLDs: + request['Domain']['DiscloseSocialData'] = 'false' + + if self.module.params['lp'] and TLD( + user=self.module.params['user'], + password=self.module.params['password'], + domain=self.module.params['domain'], + tld_cache=self.module.params['tld_cache'], + ).lp_offered(): + request['Domain']['LocalPresence'] = 'true' + + if _tld in self.TRADEMARK_COUNTRY_TLDs: + request['Domain']['Trademark'] = {'Country': request['Domain']['Owner']['CountryCode']} + + return request + + def _contacts_permitted(self): + # some tld's don't support contact-data + result = TLD( + user=self.module.params['user'], + password=self.module.params['password'], + domain=self.module.params['domain'], + action='CONTACT UPDATE', + tld_cache=self.module.params['tld_cache'], + ).contacts_permitted() + + if not result: + self.module.warn('You cannot update contact-data of this TLD.') + + return result + + def _docs_required(self, action: str, msg: str): + # checking if documentation is required for the current action or it has been forced + if TLD( + user=self.module.params['user'], + password=self.module.params['password'], + domain=self.module.params['domain'], + action=action, + tld_cache=self.module.params['tld_cache'], + ).docs_required() and not self.module.params['force']: + self.result['errors'].append(msg) + self.result['failed'] = True + return True + + return False + + def _compare_config(self, response: dict): + # build comparison dict from received settings + existing_config = response['DomainInfos']['DomainInfo'][0] + + self.result['diff']['before'] = {key: {} for key in self.MODULE_API_CONTACT_MAPPING} + self.result['diff']['after'] = {key: {} for key in self.MODULE_API_CONTACT_MAPPING} + + for attribute in self.DIFF_COMPARE_FILTER['contacts']: + for module_key, api_key in self.MODULE_API_CONTACT_MAPPING.items(): + if attribute in existing_config[api_key]: + self.result['diff']['before'][module_key][attribute] = existing_config[api_key][attribute] + + else: + self.result['diff']['before'][module_key][attribute] = None + + if attribute in self.module.params[module_key]: + self.result['diff']['after'][module_key][attribute] = self.module.params[module_key][attribute] + + else: + self.result['diff']['after'][module_key][attribute] = None + + _before_nameservers = [] + + for value in existing_config['NameServers'].values(): + _before_nameservers.append(value[self.DIFF_COMPARE_FILTER['nameservers'][0]]) + + self.result['diff']['before']['nameservers'] = self._build_nameservers(ns_list=_before_nameservers) + self.result['diff']['after']['nameservers'] = self.nameservers + + if self.result['diff']['before'] != self.result['diff']['after']: + self.result['changed'] = True + + @staticmethod + def _build_nameservers(ns_list: list) -> dict: + # build nameserver-dict from supplied list + nameservers = {} + + for i in range(1, len(ns_list) + 1): + _server = ns_list[i - 1] + + if _server is not None: + if _server.endswith('.'): + _server = _server[:-1] + + nameservers[f'NameServer{i}'] = {'HostName': _server} + + return nameservers + + +def run_module(): + # arguments we expect + module_args = dict( + user=dict(type='str', required=True), + password=dict(type='str', required=True, no_log=True), + nameservers=dict(type='list', required=True), + contact_owner=dict(type='dict', required=True), + contact_tech=dict(type='dict', required=True), + contact_admin=dict(type='dict', required=True), + contact_billing=dict(type='dict', required=True), + domain=dict(type='str', required=True, description='Domain to register'), + premium=dict(type='bool', default=False, description='If premium domains should be registered (higher costs)'), + max_price=dict(type='float', default=None, description='Set the maximal price of the domain'), + whois_hide=dict(type='bool', default=False, description='If the contact data should be hidden in whois lookups'), + update_only_ns=dict(type='bool', default=False, description='If only nameservers should be updated'), + force=dict(type='bool', default=False, description='Force changes if documentation is required'), + tld_cache=dict(type='str', required=True, description='Directory used to cache the TLDKit configurations'), + lp=dict(type='bool', default=False, description='If ascio should be used as a local presence'), + ) + module = AnsibleModule( + argument_spec=module_args, + supports_check_mode=True, + ) + + # custom conversion + module.params['domain'] = module.params['domain'].encode('idna').decode('utf-8') + + # custom argument validation => might be possible to do this in a cleaner way.. + if 13 < len(module.params['nameservers']) < 2: + module.fail_json( + msg='You need to supply between 2 and 13 nameservers for the domain!', + result=dict( + failed=True, + ) + ) + + # run check or do actual work + try: + if module.check_mode: + result = Register(module=module).check() + + else: + result = Register(module=module).set() + + # return status and changes to user + if result['failed']: + result['msg'] = 'The ASCIO-API returned an error!' + + module.exit_json(**result) + + # pylint: disable=W0718 + except Exception as error: + exc_type, _, _ = sys_exc_info() + module.fail_json( + msg='Got an error while processing the registration!', + errors=[str(exc_type), str(error), str(format_exc())], + ) + + +def main(): + run_module() + + +if __name__ == '__main__': + main() diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..479a5ec --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +# python pip requirements +requests +zeep diff --git a/requirements_lint.txt b/requirements_lint.txt new file mode 100644 index 0000000..036fcd3 --- /dev/null +++ b/requirements_lint.txt @@ -0,0 +1,7 @@ +# pip requirements +yamllint +pylint +ansible-core +ansible-lint +requests +zeep diff --git a/scripts/build.sh b/scripts/build.sh new file mode 100644 index 0000000..37da329 --- /dev/null +++ b/scripts/build.sh @@ -0,0 +1,12 @@ +#!/usr/bin/env bash + +set -e + +cd "$(dirname "$0")/.." + +echo '' +echo 'BUILDING tarball' +echo '' + +rm -f niceshopsOrg-ascio-*.tar.gz +ansible-galaxy collection build diff --git a/scripts/lint.sh b/scripts/lint.sh new file mode 100644 index 0000000..2ed079d --- /dev/null +++ b/scripts/lint.sh @@ -0,0 +1,31 @@ +#!/usr/bin/env bash + +set -e + +cd "$(dirname "$0")/.." + +echo '' +echo 'LINTING Python' +echo '' + +pylint --recursive=y . + +echo '' +echo 'LINTING Yaml' +echo '' +yamllint . + +echo '' +echo 'LINTING Ansible' +echo '' +TMP_COL_DIR='/tmp/ansible_lint/collections' +mkdir -p "$TMP_COL_DIR/ansible_collections/niceshopsOrg" +rm -f "$TMP_COL_DIR/ansible_collections/niceshopsOrg/ascio" +ln -s "$(pwd)" "$TMP_COL_DIR/ansible_collections/niceshopsOrg/ascio" +ANSIBLE_COLLECTIONS_PATH="$TMP_COL_DIR" ansible-lint -c .ansible-lint.yml +rm -rf "$TMP_COL_DIR" + +echo '' +echo 'FINISHED LINTING!' +echo '' + diff --git a/tld_config.json b/tld_config.json new file mode 100644 index 0000000..1a0277f --- /dev/null +++ b/tld_config.json @@ -0,0 +1,301 @@ +{ + "contact_keys": [ + "owner", + "billing", + "tech", + "admin" + ], + "fields": { + "company": { + "owner": [ + "FirstName", + "LastName", + "Address1", + "City", + "PostalCode", + "CountryCode", + "Phone", + "Email", + "OrgName", + "State", + "VatNumber", + "OrganisationNumber", + "Address2", + "Details", + "Fax", + "Type" + ], + "all": [ + "FirstName", + "LastName", + "Address1", + "City", + "PostalCode", + "CountryCode", + "Phone", + "Email", + "OrgName", + "State", + "OrganisationNumber", + "Address2", + "Details", + "Fax", + "Type" + ] + }, + "person": { + "owner": [ + "FirstName", + "LastName", + "Address1", + "City", + "PostalCode", + "CountryCode", + "Phone", + "Email", + "State", + "Address2", + "Details", + "Fax", + "Type", + "RegistrantDate" + ], + "all": [ + "FirstName", + "LastName", + "Address1", + "City", + "PostalCode", + "CountryCode", + "Phone", + "Email", + "State", + "Address2", + "Details", + "Fax", + "Type" + ] + } + }, + "country_overrides": { + "cz": { + "person": { + "fields": [ + "OrgName" + ] + } + }, + "dk": { + "company": { + "all": { + "Type": "V" + } + }, + "person": { + "all": { + "Type": "P" + } + } + }, + "ee": { + "company": { + "all": { + "Type": "org" + } + }, + "person": { + "all": { + "Type": "priv" + }, + "fields": [ + "OrganisationNumber" + ] + }, + "local_presence": true + }, + "es": { + "company": { + "all": { + "Type": "1" + }, + "admin": { + "Type": "0", + "OrgName": "", + "OrganisationNumber": "YOUR-TECHNICAL-CONTACT-PASSPORT-NR", + "FirstName": "YOUR-TECHNICAL-CONTACT-FIRSTNAME", + "LastName": "YOUR-TECHNICAL-CONTACT-LASTNAME" + } + }, + "person": { + "all": { + "Type": "0" + }, + "fields": [ + "OrganisationNumber" + ] + } + }, + "fi": { + "company": { + "all": { + "Type": "1" + } + }, + "person": { + "all": { + "Type": "0" + } + } + }, + "fr": { + "company": { + "all": { + "Type": "company" + } + }, + "person": { + "all": { + "Type": "individual" + } + } + }, + "hu": { + "person": { + "fields": [ + "OrganisationNumber" + ] + }, + "local_presence": true + }, + "ie": { + "company": { + "all": { + "Type": "COM" + } + }, + "person": { + "all": { + "Type": "OTH" + } + } + }, + "it": { + "company": { + "all": { + "Type": "7" + } + }, + "person": { + "all": { + "Type": "1" + }, + "fields": [ + "OrganisationNumber" + ] + } + }, + "lt": { + "company": { + "all": { + "Type": "ORG" + } + }, + "person": { + "all": { + "Type": "IND" + } + } + }, + "lv": { + "person": { + "fields": [ + "OrganisationNumber" + ] + } + }, + "nl": { + "company": { + "all": { + "Type": "BGG" + } + }, + "person": { + "all": { + "Type": "PERSOON" + } + } + }, + "ro": { + "company": { + "all": { + "Type": "c" + } + }, + "person": { + "all": { + "Type": "p" + }, + "fields": [ + "OrgName" + ] + } + }, + "ru": { + "company": { + "all": { + "Type": "ORG" + } + }, + "person": { + "all": { + "Type": "PRS" + }, + "fields": [ + "OrgName", + "OrganisationNumber" + ] + } + }, + "se": { + "person": { + "fields": [ + "OrganisationNumber" + ] + } + }, + "uk": { + "company": { + "all": { + "Type": "FCORP" + } + }, + "person": { + "all": { + "Type": "FIND" + } + } + } + }, + "default": { + "owner": { + "VatNumber": "YOUR-VAT-NUMBER" + }, + "billing": { + "Email": "YOUR-MAIL-TO-RECEIVE-DOMAIN-BILLS" + }, + "type": "company", + "all": { + "FirstName": "YOUR-REGISTRANT-FIRSTNAME", + "LastName": "YOUR-REGISTRANT-LASTNAME", + "Address1": "YOUR-ADDRESS", + "City": "YOUR-CITY", + "PostalCode": "YOUR-POSTAL-CODE", + "CountryCode": "YOUR-COUNTRY-CODE", + "Phone": "YOUR-TELEPHONE-NUMBER-IN-FORMAT-'+.'", + "Email": "YOUR-MAIL-TO-RECEIVE-DOMAIN-MAILS", + "OrgName": "YOUR-COMPANY-NAME", + "State": "YOUR-STATE-OR-PROVINCE", + "OrganisationNumber": "YOUR-INTERNATIONAL-ORG-NUMBER" + } + } +} \ No newline at end of file