diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..f4a3462 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,18 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true +indent_style = space +indent_size = 4 + +[Makefile] +indent_style = tab + +[*.{xml,yml,yaml,json}] +indent_size = 2 + +[*.md] +trim_trailing_whitespace = false diff --git a/.github/labels.json b/.github/labels.json new file mode 100644 index 0000000..b5b5923 --- /dev/null +++ b/.github/labels.json @@ -0,0 +1,20 @@ +[ + { "name": "type/feature", "color": "1D76DB", "description": "New features or enhancements" }, + { "name": "type/bug", "color": "D73A4A", "description": "Bug reports and fixes" }, + { "name": "type/documentation","color": "0E8A16", "description": "Documentation improvements" }, + { "name": "type/enhancement", "color": "5319E7", "description": "General improvements" }, + { "name": "type/refactor", "color": "6F42C1", "description": "Code refactoring" }, + { "name": "type/performance", "color": "0052CC", "description": "Performance improvements" }, + { "name": "type/accessibility","color": "7B61FF", "description": "Accessibility-related changes" }, + { "name": "type/chore", "color": "8B949E", "description": "Routine maintenance tasks" }, + + { "name": "community/help-wanted", "color": "2563EB", "description": "Help wanted from community" }, + { "name": "community/good-first-issue", "color": "8B5CF6", "description": "Good for first-time contributors" }, + + { "name": "area/web", "color": "6366F1", "description": "web-related" }, + { "name": "area/cli", "color": "6366F1", "description": "cli-related" }, + { "name": "area/config", "color": "6366F1", "description": "Configuration-related" }, + { "name": "area/build", "color": "4F46E5", "description": "Build system changes" }, + { "name": "area/dependencies", "color": "4338CA", "description": "Dependency updates" }, + { "name": "area/ci", "color": "7C3AED", "description": "CI/CD improvements" } +] diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..1cdb93f --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,147 @@ +name: CI + +on: + pull_request: + branches: [main] + push: + branches: [main] + workflow_dispatch: + inputs: + version: + description: 'Release version (e.g. v1.2.3)' + required: true + type: string + +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +env: + GO_VERSION: '1.26.4' + +jobs: + lint: + name: Lint + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + + - uses: actions/setup-go@v6 + with: + go-version: ${{ env.GO_VERSION }} + cache: true + + - name: Verify go.mod is tidy + run: | + go mod tidy + git diff --exit-code -- go.mod go.sum \ + || (echo "::error::go.mod/go.sum are not tidy — run 'make tidy'" && exit 1) + + - name: go vet + run: make vet + + - name: staticcheck + uses: dominikh/staticcheck-action@v1 + with: + version: latest + install-go: false + + test: + name: Test (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest, macos-latest] + steps: + - uses: actions/checkout@v7 + + - uses: actions/setup-go@v6 + with: + go-version: ${{ env.GO_VERSION }} + cache: true + + - name: Test + shell: bash + run: go test -race -coverprofile=coverage.out ./... + + - name: Upload coverage + uses: actions/upload-artifact@v4 + with: + name: coverage-${{ matrix.os }} + path: coverage.out + if-no-files-found: ignore + + build: + name: Build (${{ matrix.goos }}/${{ matrix.goarch }}) + needs: [lint, test] + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + - goos: linux + goarch: amd64 + - goos: linux + goarch: arm64 + - goos: darwin + goarch: amd64 + - goos: darwin + goarch: arm64 + - goos: windows + goarch: amd64 + steps: + - uses: actions/checkout@v7 + + - uses: actions/setup-go@v6 + with: + go-version: ${{ env.GO_VERSION }} + cache: true + + - name: Build + env: + GOOS: ${{ matrix.goos }} + GOARCH: ${{ matrix.goarch }} + VERSION: ${{ github.event.inputs.version || format('dev-{0}', github.sha) }} + run: make release-build + + - name: Upload build artifact + uses: actions/upload-artifact@v4 + with: + name: gs-${{ matrix.goos }}-${{ matrix.goarch }} + path: dist/gs-* + if-no-files-found: error + + release: + name: Release ${{ github.event.inputs.version }} + if: github.event_name == 'workflow_dispatch' + needs: [build] + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/checkout@v7 + + - name: Download all build artifacts + uses: actions/download-artifact@v4 + with: + pattern: gs-* + path: dist + merge-multiple: true + + - name: Generate checksums + working-directory: dist + run: sha256sum gs-* > checksums.txt + + - name: Publish release + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ github.event.inputs.version }} + name: ${{ github.event.inputs.version }} + generate_release_notes: true + files: | + dist/gs-* + dist/checksums.txt diff --git a/.github/workflows/sync-labels.yml b/.github/workflows/sync-labels.yml new file mode 100644 index 0000000..2dd90f3 --- /dev/null +++ b/.github/workflows/sync-labels.yml @@ -0,0 +1,138 @@ +name: Sync Labels + +on: + push: + paths: + - '.github/labels.json' + workflow_dispatch: + +permissions: + contents: read + issues: write + pull-requests: write + +jobs: + sync: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v7 + + - uses: actions/github-script@v9 + with: + script: | + const fs = require('fs'); + + // Read labels file + const labels = JSON.parse( + fs.readFileSync('.github/labels.json', 'utf8') + ); + + if (!Array.isArray(labels)) { + throw new Error( + '.github/labels.json must contain an array of label objects' + ); + } + + // Validate labels + for (const [index, label] of labels.entries()) { + if (!label || typeof label !== 'object') { + throw new Error( + `Label at index ${index} is not a valid object` + ); + } + + if ( + typeof label.name !== 'string' || + label.name.trim() === '' + ) { + throw new Error( + `Label at index ${index} is missing a valid "name" field: ${JSON.stringify(label)}` + ); + } + + if ( + typeof label.color !== 'string' || + !/^[0-9a-fA-F]{6}$/.test(label.color) + ) { + throw new Error( + `Label "${label.name}" has an invalid "color" field. Expected a 6-character hex code without '#'.` + ); + } + } + + // Fetch existing labels from GitHub + const existingLabels = await github.paginate( + github.rest.issues.listLabelsForRepo, + { + owner: context.repo.owner, + repo: context.repo.repo, + per_page: 100, + } + ); + + const newLabelNames = new Set( + labels.map(label => label.name.toLowerCase()) + ); + + // Delete labels that no longer exist in labels.json + for (const existingLabel of existingLabels) { + if ( + existingLabel.name && + !newLabelNames.has(existingLabel.name.toLowerCase()) + ) { + try { + await github.rest.issues.deleteLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + name: existingLabel.name, + }); + + console.log(`Deleted label: ${existingLabel.name}`); + } catch (error) { + console.log( + `Failed to delete label "${existingLabel.name}": ${error.message}` + ); + } + } + } + + // Create or update labels + for (const label of labels) { + try { + const existingLabel = existingLabels.find( + l => + l.name && + l.name.toLowerCase() === label.name.toLowerCase() + ); + + if (existingLabel) { + await github.rest.issues.updateLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + name: existingLabel.name, + new_name: label.name, + color: label.color, + description: label.description || '', + }); + + console.log(`Updated label: ${label.name}`); + } else { + await github.rest.issues.createLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + name: label.name, + color: label.color, + description: label.description || '', + }); + + console.log(`Created label: ${label.name}`); + } + } catch (error) { + console.log( + `Error processing label "${label.name}": ${error.message}` + ); + } + } + + console.log('Label synchronization completed.'); diff --git a/.gitignore b/.gitignore index 8b13789..9605401 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,34 @@ +# Local +*.html +gs +dist/ +coverage.out +# macOS +.DS_Store + +# Node / Next.js +node_modules +.next +npm-debug.log* +pnpm-debug.log* +frontend/.next +frontend/node_modules + +# Go +/.gopath +backend/.gopath +backend/bin +backend/obj +backend/server + +# Env / config +.env +.env.* +*.local + +# Logs +*.log + +# Docker artifacts +db_data \ No newline at end of file diff --git a/.mailmap b/.mailmap new file mode 100644 index 0000000..7dbc192 --- /dev/null +++ b/.mailmap @@ -0,0 +1,4 @@ +Idan Koblik +Idan Koblik + +Nitay Stain diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..f288702 --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,674 @@ + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Use with the GNU Affero General Public License. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If the program does terminal interaction, make it output a short +notice like this when it starts in an interactive mode: + + Copyright (C) + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, your program's commands +might be different; for a GUI interface, you would use an "about box". + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU GPL, see +. + + The GNU General Public License does not permit incorporating your program +into proprietary programs. If your program is a subroutine library, you +may consider it more useful to permit linking proprietary applications with +the library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. But first, please read +. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..fd2c72d --- /dev/null +++ b/Makefile @@ -0,0 +1,42 @@ +BINARY := gs +CMD := ./cmd +PKG := main +VERSION ?= dev +GOOS ?= $(shell go env GOOS) +GOARCH ?= $(shell go env GOARCH) + +BUILD_TIME := $(shell date +%Y-%m-%dT%H:%M:%S) +LDFLAGS := -s -w \ + -X '$(PKG).BUILD_TIME=$(BUILD_TIME)' \ + -X '$(PKG).VERSION=$(VERSION)' + +.PHONY: build run clean fmt vet test tidy release-build + +build: + go build -ldflags "$(LDFLAGS)" -o $(BINARY) $(CMD) + +# Cross-compiled, reproducible build used by CI release matrix. +# Output name carries the target platform, e.g. gs-linux-amd64. +release-build: + CGO_ENABLED=0 GOOS=$(GOOS) GOARCH=$(GOARCH) \ + go build -trimpath -ldflags "$(LDFLAGS)" \ + -o dist/$(BINARY)-$(GOOS)-$(GOARCH)$(if $(filter windows,$(GOOS)),.exe,) $(CMD) + +run: + go run -ldflags "$(LDFLAGS)" $(CMD) + +fmt: + go fmt ./... + +vet: + go vet ./... + +tidy: + go mod tidy + +test: + go test -race -coverprofile=coverage.out ./... + +clean: + rm -f $(BINARY) coverage.out + rm -rf dist diff --git a/README.md b/README.md index 8b13789..53236b9 100644 --- a/README.md +++ b/README.md @@ -1 +1,205 @@ +

+ GoScouter Logo +

+

GoScouter

+ +

+ A modular toolkit for scouting, probing, and analyzing networks. +

+ +

+ Fast • Extensible • Cross-Platform +

+ +

+ Documentation + · + Downloads + · + Issues +

+ +--- + +## Overview + +**GoScouter** is a modular toolkit for scouting, probing, and analyzing +networks. It bundles a collection of composable modules behind a single, +consistent command-line interface — so you can discover hosts, enumerate +services, and inspect network behavior without juggling a dozen separate +tools. + +Built with a focus on speed and extensibility, GoScouter is designed to grow +with your workflow: enable only the modules you need, script it into your +pipelines, and extend it with your own probes. + +> ⚠️ **Use responsibly.** GoScouter is intended for authorized security +> testing, research, and network administration only. Always ensure you have +> explicit permission to scan and probe the networks and hosts you target. + +## Key Features + +- **Modular architecture** — Each capability lives in its own module. Load + what you need, skip what you don't. +- **Cross-platform** — First-class support for **macOS**, **Linux**, and + **Windows**. +- **Extensible** — A clean plugin surface makes it straightforward to add + your own probes and analyzers. +- **Unified CLI** — One consistent command-line interface across every + module. +- **Analysis-ready output** — Human-readable by default, with + machine-friendly formats for scripting and automation. + +## Platform Support + +| Platform | Supported | Notes | +| -------- | :-------: | ------------------------------ | +| Linux | ✅ | Primary development platform | +| macOS | ✅ | Intel & Apple Silicon | +| Windows | ✅ | Windows 10 / 11 | + +## Installation + +### Install script + +The install script picks the right prebuilt binary for your platform from the +latest [release](https://github.com/GoScouter/GoScouter/releases), verifies its +SHA-256 checksum, and installs it. + +**Linux and macOS** + +```bash +curl -fsSL https://raw.githubusercontent.com/GoScouter/GoScouter/main/scripts/install.sh | sh +``` + +**Windows** (PowerShell) + +```powershell +irm https://raw.githubusercontent.com/GoScouter/GoScouter/main/scripts/install.ps1 | iex +``` + +By default `gs` lands in `/usr/local/bin` when that's writable and `~/.local/bin` +otherwise; on Windows it goes to `%LOCALAPPDATA%\Programs\GoScouter`, which the +script adds to your user `PATH`. + +Both scripts take the same options: + +| Linux / macOS | Windows | Environment | Description | +| ----------------- | ------------------- | ---------------- | --------------------------------------- | +| `--version ` | `-Version ` | `GS_VERSION` | Release tag to install (default: latest) | +| `--dir ` | `-InstallDir `| `GS_INSTALL_DIR` | Install directory | +| `--no-verify` | `-NoVerify` | `GS_NO_VERIFY=1` | Skip the checksum check | +| — | `-NoPath` | — | Leave `PATH` untouched | + +A piped script can't take flags, so either use the environment variables: + +```bash +GS_VERSION=v1.2.3 GS_INSTALL_DIR=~/bin \ + sh -c "$(curl -fsSL https://raw.githubusercontent.com/GoScouter/GoScouter/main/scripts/install.sh)" +``` + +or download it first and run it with flags: + +```bash +curl -fsSL https://raw.githubusercontent.com/GoScouter/GoScouter/main/scripts/install.sh -o install.sh +sh install.sh --version v1.2.3 --dir ~/bin +``` + +### Manual download + +Alternatively, grab the binary for your platform from the +[Releases](https://github.com/GoScouter/GoScouter/releases) page and put it on +your `PATH`: + +```bash +chmod +x gs-linux-amd64 +sudo mv gs-linux-amd64 /usr/local/bin/gs +``` + +### Build from source + +Building from source requires the [Go toolchain](https://go.dev/dl/) +(1.26 or newer), `make`, and `git`. + +```bash +# Clone the repository +git clone https://github.com/GoScouter/GoScouter.git +cd GoScouter + +# Build the binary (produces ./gs) +make build +``` + +`make build` compiles the project from `./cmd` and produces a `gs` binary in +the current directory. To cross-compile a release binary for a specific +platform, use the `release-build` target: + +```bash +# Cross-compile for a specific OS/arch — output lands in dist/ +make release-build GOOS=linux GOARCH=amd64 # dist/gs-linux-amd64 +make release-build GOOS=darwin GOARCH=arm64 # dist/gs-darwin-arm64 +make release-build GOOS=windows GOARCH=amd64 # dist/gs-windows-amd64.exe +``` + +### Other Makefile targets + +| Target | Description | +| -------------------- | ---------------------------------------------- | +| `make build` | Build the `gs` binary | +| `make run` | Build and run directly via `go run` | +| `make test` | Run the test suite with the race detector | +| `make fmt` | Format the source with `go fmt` | +| `make vet` | Run `go vet` | +| `make tidy` | Tidy `go.mod` / `go.sum` | +| `make clean` | Remove build artifacts | + +## Usage + +GoScouter runs as an interactive shell. Point it at a target with the +`--target` flag (the target **must** include an `http://` or `https://` +prefix), and GoScouter drops you into its prompt: + +```bash +gs --target https://example.com +``` + +Once inside the shell, type `help` to list the available built-in commands: + +### Example session + +```bash +$ gs --target https://example.com +# ... banner ... +gs> help # list built-in commands +gs> info # show tool information +gs> install https://github.com/GoScouter/some-module +gs> exit # leave the shell (or press Ctrl-D) +``` + +
+ +CLI interface demo + + +![demo1](assets/demo.png) +
+ +
+ +Scanning demo + + +![demo2](assets/demo2.png) +
+ +## Contributing + +Contributions are welcome! Whether it's a bug report, a feature request, a new +module, or documentation improvements, we'd love your help. + +- Found a bug or have an idea? [Open an issue](https://github.com/GoScouter/GoScouter/issues). +- Want to contribute code? Fork the repository, create a branch, and open a + pull request. diff --git a/assets/demo.png b/assets/demo.png new file mode 100644 index 0000000..a76e313 Binary files /dev/null and b/assets/demo.png differ diff --git a/assets/demo2.png b/assets/demo2.png new file mode 100644 index 0000000..f07d63d Binary files /dev/null and b/assets/demo2.png differ diff --git a/assets/logo.png b/assets/logo.png new file mode 100644 index 0000000..6e83ba2 Binary files /dev/null and b/assets/logo.png differ diff --git a/cmd/main.go b/cmd/main.go new file mode 100644 index 0000000..e10dddb --- /dev/null +++ b/cmd/main.go @@ -0,0 +1,149 @@ +package main + +import ( + "bufio" + "errors" + "flag" + "fmt" + "io" + "log/slog" + "os" + "os/signal" + "strings" + "sync/atomic" + "syscall" + + "goscouter/internal" + "goscouter/internal/cmd" + "goscouter/internal/logger" + "goscouter/internal/module" + "goscouter/internal/style" + "goscouter/internal/terminal" + "goscouter/internal/utils" + "goscouter/internal/versions" +) + +var ( + BUILD_TIME string + VERSION string +) + +var interrupted atomic.Bool + +func main() { + version := flag.Bool("version", false, "Returns goscouter cli version") + targetSite := flag.String("target", "", "The site to target") + flag.Parse() + + if *version { + fmt.Println("Version:", VERSION) + os.Exit(0) + } + + if *targetSite == "" { + fmt.Println("Usage: gs --target ") + os.Exit(1) + } + + printBanner() + + err := logger.SetupLogger(logger.LoggerConfig{ + Console: false, + Level: slog.LevelInfo, + }) + if err != nil { + panic(err) + } + + if err = versions.SuggestUpdate(VERSION); err != nil { + logger.Log.Warn("Update check failed", "error", err) + fmt.Printf("%s\n\n", style.Error("Update: "+err.Error())) + return + } + + fmt.Printf("%s %s\n\n", style.Gray("Target:"), style.Bold(*targetSite)) + logger.Log.Info("Entering terminal raw mode") + state, err := terminal.NewShellState() + if err != nil { + panic(err) + } + + logger.Log.Info("Loading modules") + moduleManager := module.NewManager() + + logger.Log.Info("Starting command manager") + commandManager, err := cmd.NewManager(*targetSite, moduleManager) + if err != nil { + panic(err) + } + + sigChan := make(chan os.Signal, 1) + signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM) + + go func() { + <-sigChan + interrupted.Store(true) + }() + + reader := bufio.NewReader(os.Stdin) + for !interrupted.Load() { + fmt.Print(style.Prompt()) + + input, err := terminal.ReadLine(reader, os.Stdout, state) + if err != nil { + if errors.Is(err, terminal.ErrInterrupted) { + // Ctrl-C: abandon the current line and prompt again. + continue + } + if errors.Is(err, io.EOF) { + // Ctrl-D on an empty line: exit the shell. + break + } + break + } + + input = strings.TrimSpace(input) + if input == "" { + // Blank line: just re-prompt instead of reporting an empty command. + continue + } + + state.AddHistory(input) + parts := strings.Fields(input) + + command, err := commandManager.Get(parts[0]) + if err != nil { + fmt.Printf("%s\r\n", style.Error(err.Error())) + continue + } + + err = command.Exec(parts[1:]) + if err != nil { + if errors.Is(err, cmd.ErrExit) { + break + } + + fmt.Printf("%s\r\n", style.Error(err.Error())) + continue + } + } + + logger.Log.Info("Exiting terminal raw mode, restoring old state") + defer state.Restore() +} + +func printBanner() { + buildTime := BUILD_TIME + if buildTime == "" { + buildTime = "unknown" + } + internal.BuildTime = buildTime + + version := VERSION + if version == "" { + version = "dev" + } + internal.Version = version + + utils.PrintBanner(internal.Version, internal.BuildTime) +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..a4be3e0 --- /dev/null +++ b/go.mod @@ -0,0 +1,27 @@ +module goscouter + +go 1.26.4 + +require ( + github.com/GoScouter/sdk v0.1.0 + github.com/google/go-github v17.0.0+incompatible + golang.org/x/term v0.45.0 + gopkg.in/src-d/go-git.v4 v4.13.1 +) + +require ( + github.com/Microsoft/go-winio v0.6.2 // indirect + github.com/emirpasic/gods v1.18.1 // indirect + github.com/google/go-querystring v1.2.0 // indirect + github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect + github.com/kevinburke/ssh_config v1.6.0 // indirect + github.com/mitchellh/go-homedir v1.1.0 // indirect + github.com/sergi/go-diff v1.4.0 // indirect + github.com/src-d/gcfg v1.4.0 // indirect + github.com/xanzy/ssh-agent v0.3.3 // indirect + golang.org/x/crypto v0.54.0 // indirect + golang.org/x/net v0.57.0 // indirect + golang.org/x/sys v0.47.0 // indirect + gopkg.in/src-d/go-billy.v4 v4.3.2 // indirect + gopkg.in/warnings.v0 v0.1.2 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..5fb29d0 --- /dev/null +++ b/go.sum @@ -0,0 +1,112 @@ +github.com/GoScouter/sdk v0.1.0 h1:2NFlDqEbj7GY/qXz99xCtNID3WsQBbbeJOLzY2p+qwE= +github.com/GoScouter/sdk v0.1.0/go.mod h1:HofZHRCh+FmH/JtWiku5yX3p9A6IagzrvHzclTdUf8o= +github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY= +github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= +github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= +github.com/alcortesm/tgz v0.0.0-20161220082320-9c5fe88206d7 h1:uSoVVbwJiQipAclBbw+8quDsfcvFjOpI5iCf4p/cqCs= +github.com/alcortesm/tgz v0.0.0-20161220082320-9c5fe88206d7/go.mod h1:6zEj6s6u/ghQa61ZWa/C2Aw3RkjiTBOix7dkqa1VLIs= +github.com/anmitsu/go-shlex v0.0.0-20161002113705-648efa622239 h1:kFOfPq6dUM1hTo4JG6LR5AXSUEsOjtdm0kw0FtQtMJA= +github.com/anmitsu/go-shlex v0.0.0-20161002113705-648efa622239/go.mod h1:2FmKhYUyUczH0OGQWaF5ceTx0UBShxjsH6f8oGKYe2c= +github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= +github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= +github.com/creack/pty v1.1.7/go.mod h1:lj5s0c3V2DBrqTV7llrYr5NG6My20zk30Fl46Y7DoTY= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/emirpasic/gods v1.12.0/go.mod h1:YfzfFFoVP/catgzJb4IKIqXjX78Ha8FMSDh3ymbK86o= +github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc= +github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ= +github.com/flynn/go-shlex v0.0.0-20150515145356-3f9db97f8568/go.mod h1:xEzjJPgXI435gkrCt3MPfRiAkVrwSbHsst4LCFVfpJc= +github.com/gliderlabs/ssh v0.2.2 h1:6zsha5zo/TWhRhwqCD3+EarCAgZ2yN28ipRnGPnwkI0= +github.com/gliderlabs/ssh v0.2.2/go.mod h1:U7qILu1NlMHj9FlMhZLlkCdDnU1DBEAqr0aevW3Awn0= +github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-github v17.0.0+incompatible h1:N0LgJ1j65A7kfXrZnUDaYCs/Sf4rEjNlfyDHW9dolSY= +github.com/google/go-github v17.0.0+incompatible/go.mod h1:zLgOLi98H3fifZn+44m+umXrS52loVEgC2AApnigrVQ= +github.com/google/go-querystring v1.2.0 h1:yhqkPbu2/OH+V9BfpCVPZkNmUXhb2gBxJArfhIxNtP0= +github.com/google/go-querystring v1.2.0/go.mod h1:8IFJqpSRITyJ8QhQ13bmbeMBDfmeEJZD5A0egEOmkqU= +github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A= +github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo= +github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= +github.com/kevinburke/ssh_config v0.0.0-20190725054713-01f96b0aa0cd/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM= +github.com/kevinburke/ssh_config v1.6.0 h1:J1FBfmuVosPHf5GRdltRLhPJtJpTlMdKTBjRgTaQBFY= +github.com/kevinburke/ssh_config v1.6.0/go.mod h1:q2RIzfka+BXARoNexmF9gkxEX7DmvbW9P4hIVx2Kg4M= +github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/pty v1.1.8/go.mod h1:O1sed60cT9XZ5uDucP5qwvh+TE3NnUj51EiZO/lmSfw= +github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= +github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= +github.com/pelletier/go-buffruneio v0.2.0/go.mod h1:JkE26KsDizTr40EUHkXVtNPvgGtbSNq5BcowyYOWdKo= +github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I= +github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo= +github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw= +github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= +github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= +github.com/src-d/gcfg v1.4.0 h1:xXbNR5AlLSA315x2UO+fTSSAXCDf+Ar38/6oyGbDKQ4= +github.com/src-d/gcfg v1.4.0/go.mod h1:p/UMsR43ujA89BJY9duynAwIpvqEujIH/jFlfL7jWoI= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/xanzy/ssh-agent v0.2.1/go.mod h1:mLlQY/MoOhWBj+gOGMQkOeiEvkx+8pJSI+0Bx9h2kr4= +github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM= +github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw= +golang.org/x/crypto v0.0.0-20190219172222-a4c6cb3142f2/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20190701094942-4def268fd1a4/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= +golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw= +golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20190724013045-ca1201d0de80/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE= +golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190221075227-b4e8571b14e0/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0= +golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= +golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190729092621-ff9f1409240a/go.mod h1:jcCCGcm9btYwXyDqrUWc6MKQKKGJCWEQ3AfLSRIbEuI= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/src-d/go-billy.v4 v4.3.2 h1:0SQA1pRztfTFx2miS8sA97XvooFeNOmvUenF4o0EcVg= +gopkg.in/src-d/go-billy.v4 v4.3.2/go.mod h1:nDjArDMp+XMs1aFAESLRjfGSgfvoYN0hDfzEk0GjC98= +gopkg.in/src-d/go-git-fixtures.v3 v3.5.0 h1:ivZFOIltbce2Mo8IjzUHAFoq/IylO9WHhNOAJK+LsJg= +gopkg.in/src-d/go-git-fixtures.v3 v3.5.0/go.mod h1:dLBcvytrw/TYZsNTWCnkNF2DSIlzWYqTe3rJR56Ac7g= +gopkg.in/src-d/go-git.v4 v4.13.1 h1:SRtFyV8Kxc0UP7aCHcijOMQGPxHSmMOPrzulQWolkYE= +gopkg.in/src-d/go-git.v4 v4.13.1/go.mod h1:nx5NYcxdKxq5fpltdHnPa2Exj4Sx0EclMWZQbYDu2z8= +gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME= +gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= diff --git a/internal/cmd/clear.go b/internal/cmd/clear.go new file mode 100644 index 0000000..b9a673a --- /dev/null +++ b/internal/cmd/clear.go @@ -0,0 +1,26 @@ +package cmd + +import ( + "fmt" + "goscouter/internal" + "goscouter/internal/utils" +) + +type ClearCommand struct{} + +func (cmd *ClearCommand) Name() string { + return "clear" +} + +func (cmd *ClearCommand) Description() string { + return "Clear's current buffer" +} + +const clear = "\033[2J\033[H" + +func (cmd *ClearCommand) Exec(args []string) error { + fmt.Print(clear) + utils.PrintBanner(internal.Version, internal.BuildTime) + + return nil +} diff --git a/internal/cmd/clear_test.go b/internal/cmd/clear_test.go new file mode 100644 index 0000000..493e31f --- /dev/null +++ b/internal/cmd/clear_test.go @@ -0,0 +1,63 @@ +package cmd + +import ( + "io" + "log/slog" + "os" + "strings" + "testing" + + "goscouter/internal/logger" +) + +func TestMain(m *testing.M) { + logger.Log = slog.New(slog.NewTextHandler(io.Discard, nil)) + os.Exit(m.Run()) +} + +func captureStdout(t *testing.T, fn func()) string { + t.Helper() + + orig := os.Stdout + r, w, err := os.Pipe() + if err != nil { + t.Fatalf("os.Pipe: %v", err) + } + os.Stdout = w + + done := make(chan string, 1) + go func() { + out, _ := io.ReadAll(r) + done <- string(out) + }() + + fn() + + w.Close() + os.Stdout = orig + return <-done +} + +func TestClearCommandMetadata(t *testing.T) { + c := &ClearCommand{} + if c.Name() != "clear" { + t.Fatalf("expected name %q, got %q", "clear", c.Name()) + } + if c.Description() == "" { + t.Fatal("expected a non-empty description") + } +} + +func TestClearCommandExec(t *testing.T) { + c := &ClearCommand{} + + out := captureStdout(t, func() { + if err := c.Exec(nil); err != nil { + t.Fatalf("unexpected error: %v", err) + } + }) + + if !strings.Contains(out, clear) { + t.Fatalf("expected output to contain the clear escape sequence, got %q", out) + } +} diff --git a/internal/cmd/command.go b/internal/cmd/command.go new file mode 100644 index 0000000..16dad5e --- /dev/null +++ b/internal/cmd/command.go @@ -0,0 +1,125 @@ +package cmd + +import ( + "fmt" + "os" + "path/filepath" + "runtime" + "strings" + + "goscouter/internal/logger" + "goscouter/internal/module" +) + +func execSuffix() string { + if runtime.GOOS == "windows" { + return ".exe" + } + return "" +} + +func commandName(path string) string { + base := filepath.Base(path) + return strings.TrimSuffix(base, execSuffix()) +} + +type Command interface { + Name() string + Description() string + Exec(args []string) error +} + +type Manager struct { + Commands map[string]Command + Target string +} + +func (cm *Manager) SetTarget(target string) { + cm.Target = target +} + +func filePathWalkDir(root string) ([]string, error) { + var files []string + err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error { + if !info.IsDir() { + files = append(files, path) + } + return nil + }) + return files, err +} + +func NewManager(target string, moduleManager *module.Manager) (*Manager, error) { + cm := &Manager{ + Commands: make(map[string]Command), + Target: target, + } + + logger.Log.Info("Loading built-in commands") + cm.Add(&InfoCommand{}) + cm.Add(&ExitCommand{}) + cm.Add(&ClearCommand{}) + cm.Add(&InstallCommand{Manager: cm}) + cm.Add(&UninstallCommand{Manager: cm}) + cm.Add(&HelpCommand{Manager: cm}) + cm.Add(&TargetCommand{Manager: cm}) + + logger.Log.Info("Loaded built-in commands.") + for k := range cm.Commands { + logger.Log.Info(fmt.Sprintf("%s command", k)) + } + + logger.Log.Info("Loading external commands") + cacheDir, err := os.UserCacheDir() + if err != nil { + return nil, err + } + + cacheDir = filepath.Join(cacheDir, "gs") + if err := os.MkdirAll(cacheDir, 0o755); err != nil { + return nil, err + } + + if moduleManager != nil { + mods := moduleManager.GetAll() + for _, mod := range mods { + cm.Add(&ModuleCommand{ + Manager: cm, + Module: mod, + }) + } + } + + logger.Log.Info(fmt.Sprintf("Looking at: %s", cacheDir)) + external, err := filePathWalkDir(cacheDir) + if err != nil { + return nil, err + } + + for _, ex := range external { + cm.Add(&ExternalCommand{ + Manager: cm, + ModuleName: commandName(ex), + Module: ex, + }) + } + + return cm, nil +} + +func (cm *Manager) Get(name string) (Command, error) { + cmd, ok := cm.Commands[name] + if ok { + return cmd, nil + } + + return nil, fmt.Errorf("%s - command does not exists", name) +} + +func (cm *Manager) Add(cmd Command) { + cm.Commands[cmd.Name()] = cmd +} + +func (cm *Manager) Remove(name string) { + delete(cm.Commands, name) +} diff --git a/internal/cmd/command_test.go b/internal/cmd/command_test.go new file mode 100644 index 0000000..7391b81 --- /dev/null +++ b/internal/cmd/command_test.go @@ -0,0 +1,85 @@ +package cmd + +import ( + "errors" + "testing" +) + +type stubCommand struct { + name string + description string + execErr error +} + +func (c *stubCommand) Name() string { return c.name } +func (c *stubCommand) Description() string { return c.description } +func (c *stubCommand) Exec(args []string) error { return c.execErr } + +func TestNewCommandManagerRegistersExit(t *testing.T) { + cm, err := NewManager("", nil) + if err != nil { + t.Fatalf("expected command manager, got error: %v", err) + } + + got, err := cm.Get("exit") + if err != nil { + t.Fatalf("expected exit command to be registered, got error: %v", err) + } + if _, ok := got.(*ExitCommand); !ok { + t.Fatalf("expected *ExitCommand, got %T", got) + } +} + +func TestGetCommandUnknown(t *testing.T) { + cm, err := NewManager("", nil) + if err != nil { + t.Fatalf("expected command manager, got error: %v", err) + } + + if _, err := cm.Get("nope"); err == nil { + t.Fatal("expected error for unknown command, got nil") + } +} + +func TestAddAndGetCommand(t *testing.T) { + cm, err := NewManager("", nil) + if err != nil { + t.Fatalf("expected command manager, got error: %v", err) + } + + cmd := &stubCommand{name: "hello"} + cm.Add(cmd) + + got, err := cm.Get("hello") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != cmd { + t.Fatalf("expected the added command back, got %#v", got) + } +} + +func TestRemoveCommand(t *testing.T) { + cm, err := NewManager("", nil) + if err != nil { + t.Fatalf("expected command manager, got error: %v", err) + } + + cm.Add(&stubCommand{name: "hello"}) + cm.Remove("hello") + + if _, err := cm.Get("hello"); err == nil { + t.Fatal("expected error after removing command, got nil") + } +} + +func TestExitCommand(t *testing.T) { + cmd := &ExitCommand{} + + if cmd.Name() != "exit" { + t.Fatalf("expected name %q, got %q", "exit", cmd.Name()) + } + if err := cmd.Exec(nil); !errors.Is(err, ErrExit) { + t.Fatalf("expected ErrExit, got %v", err) + } +} diff --git a/internal/cmd/exit.go b/internal/cmd/exit.go new file mode 100644 index 0000000..606c24a --- /dev/null +++ b/internal/cmd/exit.go @@ -0,0 +1,21 @@ +package cmd + +import ( + "errors" +) + +type ExitCommand struct{} + +func (cmd *ExitCommand) Name() string { + return "exit" +} + +func (cmd *ExitCommand) Description() string { + return "Exit's gs shell." +} + +var ErrExit = errors.New("exit shell") + +func (cmd *ExitCommand) Exec(args []string) error { + return ErrExit +} diff --git a/internal/cmd/exit_test.go b/internal/cmd/exit_test.go new file mode 100644 index 0000000..57aac3a --- /dev/null +++ b/internal/cmd/exit_test.go @@ -0,0 +1,33 @@ +package cmd + +import ( + "errors" + "testing" +) + +func TestExitCommandMetadata(t *testing.T) { + c := &ExitCommand{} + if c.Name() != "exit" { + t.Fatalf("expected name %q, got %q", "exit", c.Name()) + } + if c.Description() == "" { + t.Fatal("expected a non-empty description") + } +} + +func TestExitCommandExec(t *testing.T) { + c := &ExitCommand{} + + err := c.Exec(nil) + if !errors.Is(err, ErrExit) { + t.Fatalf("expected ErrExit, got %v", err) + } +} + +func TestExitCommandExecIgnoresArgs(t *testing.T) { + c := &ExitCommand{} + + if err := c.Exec([]string{"some", "args"}); !errors.Is(err, ErrExit) { + t.Fatalf("expected ErrExit regardless of args, got %v", err) + } +} diff --git a/internal/cmd/external.go b/internal/cmd/external.go new file mode 100644 index 0000000..f7a731c --- /dev/null +++ b/internal/cmd/external.go @@ -0,0 +1,37 @@ +package cmd + +import ( + "fmt" + + "github.com/GoScouter/sdk" +) + +type ExternalCommand struct { + Manager *Manager + ModuleName string + Module string +} + +func (cmd *ExternalCommand) Name() string { + return cmd.ModuleName +} + +func (cmd *ExternalCommand) Description() string { + return "No need :O" +} + +func (cmd *ExternalCommand) Exec(args []string) error { + bin, err := sdk.Open(cmd.Module) + if err != nil { + return err + } + defer bin.Close() + + res, err := bin.Scout(cmd.Manager.Target, args) + if err != nil { + return err + } + + fmt.Println(res.Render()) + return nil +} diff --git a/internal/cmd/help.go b/internal/cmd/help.go new file mode 100644 index 0000000..8310c54 --- /dev/null +++ b/internal/cmd/help.go @@ -0,0 +1,62 @@ +package cmd + +import ( + "fmt" + "sort" + "strings" + + "goscouter/internal/style" +) + +type HelpCommand struct { + Manager *Manager +} + +func (cmd *HelpCommand) Name() string { + return "help" +} + +func (cmd *HelpCommand) Description() string { + return "Returns list of avaiable built-in commands." +} + +func (cmd *HelpCommand) Exec(args []string) error { + var b strings.Builder + + b.WriteString(style.Bold("Available commands") + "\r\n") + if cmd.Manager != nil { + names := make([]string, 0, len(cmd.Manager.Commands)) + width := 0 + for name, command := range cmd.Manager.Commands { + if _, ok := command.(*ExternalCommand); ok { + continue + } + names = append(names, name) + if len(name) > width { + width = len(name) + } + } + sort.Strings(names) + + for _, name := range names { + command := cmd.Manager.Commands[name] + pad := strings.Repeat(" ", width-len(name)) + b.WriteString(fmt.Sprintf(" %s%s %s\r\n", + style.Cyan(name), pad, style.Dim(command.Description()))) + } + } + + b.WriteString("\r\n" + style.Bold("Flags") + "\r\n") + b.WriteString(fmt.Sprintf(" %s %s\r\n", + style.Cyan("--target"), + style.Dim("Determines the site that goscouter will target (requires http/https prefix)."), + )) + + b.WriteString(fmt.Sprintf(" %s %s\r\n", + style.Cyan("--version"), + style.Dim("Returns goscouter cli version"), + )) + + fmt.Printf("%s\r\n", b.String()) + return nil +} diff --git a/internal/cmd/help_test.go b/internal/cmd/help_test.go new file mode 100644 index 0000000..3e81c19 --- /dev/null +++ b/internal/cmd/help_test.go @@ -0,0 +1,37 @@ +package cmd + +import ( + "strings" + "testing" +) + +func TestHelpCommandMetadata(t *testing.T) { + c := &HelpCommand{} + if c.Name() != "help" { + t.Fatalf("expected name %q, got %q", "help", c.Name()) + } + if c.Description() == "" { + t.Fatal("expected a non-empty description") + } +} + +func TestHelpCommandExecListsCommands(t *testing.T) { + m := &Manager{Commands: make(map[string]Command)} + m.Add(&stubCommand{name: "alpha", description: "first"}) + m.Add(&stubCommand{name: "beta", description: "second"}) + + c := &HelpCommand{Manager: m} + m.Add(c) + + out := captureStdout(t, func() { + if err := c.Exec(nil); err != nil { + t.Fatalf("unexpected error: %v", err) + } + }) + + for _, want := range []string{"help", "alpha", "first", "beta", "second", "--target"} { + if !strings.Contains(out, want) { + t.Fatalf("expected help output to contain %q, got:\n%s", want, out) + } + } +} diff --git a/internal/cmd/info.go b/internal/cmd/info.go new file mode 100644 index 0000000..9c67c68 --- /dev/null +++ b/internal/cmd/info.go @@ -0,0 +1,134 @@ +package cmd + +import ( + "fmt" + "regexp" + "runtime" + "strings" + + "goscouter/internal/style" + "goscouter/internal" +) + +type InfoCommand struct{} + +func (cmd *InfoCommand) Name() string { + return "info" +} + +func (cmd *InfoCommand) Description() string { + return "Shows general information about the tool" +} + +var logo = []string{ + style.Cyan(" --==============--"), + style.Cyan(" .-==-.===oooo=oooooo=ooooo===--===-"), + style.Cyan(" .== =o=") + style.White("oGGGGGG") + style.Cyan("o=oo=o") + style.Green("GGGGGGG") + style.Cyan("G=o=") + style.Green(" ╱") + style.Red("◉"), + style.Cyan(" -o= oo=") + style.White("G .=GGGGG") + style.Cyan("o=o=") + style.Green("= .=GGGGG") + style.Cyan("=ooo") + style.Green("══▊"), + style.Cyan(" .-=oo=") + style.White("o==oGGGGG") + style.Cyan("=oo=") + style.Green("oooGGGGGo") + style.Cyan("=oooo."), + style.Cyan(" -ooooo") + style.White("=oooooo") + style.Cyan("=") + style.Yellow(". .") + style.Cyan("=") + style.White("=ooo==") + style.Cyan("oooooo-"), + style.Cyan(" -ooooooooooo") + style.Yellow("====_====") + style.Cyan("ooooooooooo="), + style.Cyan(" -oooooooooooo") + style.Yellow("==") + style.White("#") + style.Cyan(".") + style.White("#") + style.Yellow("==") + style.Cyan("ooooooooooooo"), + style.Cyan(" -ooooooooooooo=") + style.White("#") + style.Cyan(".") + style.White("#") + style.Cyan("=oooooooooooooo"), + style.Cyan(" .oooooooooooooooooooooooooooooooo."), + style.Cyan(" oooooooooooooooooooooooooooooooo."), + style.Yellow(" ..") + style.Cyan("oooooooooooooooooooooooooooooooo") + style.Yellow(".."), + style.Yellow("-=o-") + style.Cyan("=ooooooooooooooooooooooooooooooo") + style.Yellow("-oo."), + style.Yellow(".=- ") + style.Cyan("oooooooooooooooooooooooooooooooo") + style.Yellow("-.-"), + style.Cyan(" .oooooooooooooooooooooooooooooooo-"), + style.Cyan(" -oooooooooooooooooooooooooooooooo-"), + style.Cyan(" -oooooooooooooooooooooooooooooooo-"), + style.Cyan(" -oooooooooooooooooooooooooooooooo-"), + style.Cyan(" .oooooooooooooooooooooooooooooooo"), + style.Cyan(" =oooooooooooooooooooooooooooooo-"), + style.Cyan(" .=oooooooooooooooooooooooooooo-"), + style.Cyan(" -=oooooooooooooooooooooooo=."), + style.Yellow(" =oo") + style.Cyan("====oooooooooooooooo==-") + style.Yellow("oo=-"), + style.Yellow(" .-==- ") + style.Cyan(".--=======--- ") + style.Yellow(".==-"), +} + +var ansiRE = regexp.MustCompile("\x1b\\[[0-9;]*m") + +func visibleWidth(s string) int { + return len([]rune(ansiRE.ReplaceAllString(s, ""))) +} + +func field(key, value string) string { + return style.Bold(style.White(key)) + style.Gray(" : ") + value +} + +func colorSwatch() string { + const block = "███" + return style.Red(block) + style.Yellow(block) + style.Green(block) + + style.Cyan(block) + style.Purple(block) + style.White(block) +} + +func infoLines() []string { + title := style.Bold(style.Cyan("GoScouter")) + rule := style.Gray(strings.Repeat("─", visibleWidth(title)+9)) + + return []string{ + title, + rule, + "", + field("GitHub ", style.Green("github.com/GoScouter/goscouter")), + field("Website ", style.Cyan("https://goscouter.github.io/")), + "", + field("Version ", style.Green(internal.Version)), + field("Go ", style.Yellow(strings.TrimPrefix(runtime.Version(), "go"))), + field("System ", style.Yellow(runtime.GOOS+" / "+runtime.GOARCH)), + field("License ", style.Yellow("GPL-3.0")), + "", + style.Bold(style.White("Purpose")), + style.Gray(" • Scouting"), + style.Gray(" • Network probing"), + style.Gray(" • Enumeration"), + style.Gray(" • Analysis"), + "", + colorSwatch(), + } +} + +func (cmd *InfoCommand) Exec(args []string) error { + info := infoLines() + + logoWidth := 0 + for _, line := range logo { + if w := visibleWidth(line); w > logoWidth { + logoWidth = w + } + } + + const gap = 3 + rows := len(logo) + if len(info) > rows { + rows = len(info) + } + + var b strings.Builder + b.WriteString("\r\n") + for i := 0; i < rows; i++ { + logoLine := "" + if i < len(logo) { + logoLine = style.BoldAll(logo[i]) + } + + padding := logoWidth + gap - visibleWidth(logoLine) + if padding < 0 { + padding = 0 + } + + b.WriteString(logoLine) + b.WriteString(strings.Repeat(" ", padding)) + + if i < len(info) { + b.WriteString(info[i]) + } + + b.WriteString("\r\n") + } + b.WriteString("\r\n") + + fmt.Print(b.String()) + return nil +} diff --git a/internal/cmd/info_test.go b/internal/cmd/info_test.go new file mode 100644 index 0000000..cb3374d --- /dev/null +++ b/internal/cmd/info_test.go @@ -0,0 +1,57 @@ +package cmd + +import ( + "runtime" + "strings" + "testing" + + "goscouter/internal" +) + +func TestInfoCommandMetadata(t *testing.T) { + c := &InfoCommand{} + if c.Name() != "info" { + t.Fatalf("expected name %q, got %q", "info", c.Name()) + } + if c.Description() == "" { + t.Fatal("expected a non-empty description") + } +} + +func TestInfoCommandExec(t *testing.T) { + internal.Version = "1.2.3" + + c := &InfoCommand{} + + out := captureStdout(t, func() { + if err := c.Exec(nil); err != nil { + t.Fatalf("unexpected error: %v", err) + } + }) + + wants := []string{ + "GoScouter", + "github.com/GoScouter/goscouter", + "https://goscouter.github.io/", + "1.2.3", + strings.TrimPrefix(runtime.Version(), "go"), + runtime.GOOS + " / " + runtime.GOARCH, + "GPL-3.0", + "Purpose", + } + for _, want := range wants { + if !strings.Contains(out, want) { + t.Fatalf("expected info output to contain %q, got:\n%s", want, out) + } + } +} + +func TestVisibleWidthStripsANSI(t *testing.T) { + styled := "\x1b[31mabc\x1b[0m" + if got := visibleWidth(styled); got != 3 { + t.Fatalf("expected visible width 3, got %d", got) + } + if got := visibleWidth("héllo"); got != 5 { + t.Fatalf("expected visible width 5 for multibyte string, got %d", got) + } +} diff --git a/internal/cmd/install.go b/internal/cmd/install.go new file mode 100644 index 0000000..7e32853 --- /dev/null +++ b/internal/cmd/install.go @@ -0,0 +1,63 @@ +package cmd + +import ( + "fmt" + + "goscouter/internal/logger" + "goscouter/internal/module" + "goscouter/internal/style" +) + +type InstallCommand struct { + Manager *Manager +} + +func (cmd *InstallCommand) Name() string { + return "install" +} + +func (cmd *InstallCommand) Description() string { + return "Installs a module" +} + +func (cmd *InstallCommand) Exec(args []string) error { + if len(args) != 1 { + return fmt.Errorf("usage: install ") + } + + url := args[0] + if url == "" { + return fmt.Errorf("usage: install ") + } + + logger.Log.Info(fmt.Sprintf("Installing module from %q\r\n", url)) + ref := module.ParseModule(url) + manifest, err := module.ResolveManifest(ref) + if err != nil { + return err + } + + binaryPath, err := module.Download(manifest, ref.Version) + if err != nil { + return err + } + + return cmd.register(binaryPath) +} + +func (cmd *InstallCommand) register(binaryPath string) error { + if cmd.Manager == nil { + return nil + } + + name := commandName(binaryPath) + cmd.Manager.Add(&ExternalCommand{ + Manager: cmd.Manager, + ModuleName: name, + Module: binaryPath, + }) + + fmt.Printf("%s\r\n", style.Successf("Command %s is now available", style.Bold(name))) + logger.Log.Info(fmt.Sprintf("Registered external command %q from %s", name, binaryPath)) + return nil +} diff --git a/internal/cmd/install_test.go b/internal/cmd/install_test.go new file mode 100644 index 0000000..4ee582a --- /dev/null +++ b/internal/cmd/install_test.go @@ -0,0 +1,213 @@ +package cmd + +import ( + "crypto/sha256" + "encoding/hex" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + + "goscouter/internal/module" +) + +const testVersion = "1.0.0" + +func releaseKey() string { + return testVersion + "/" + runtime.GOOS + "-" + runtime.GOARCH +} + +func checksumOf(b []byte) string { + sum := sha256.Sum256(b) + return hex.EncodeToString(sum[:]) +} + +func serveBinary(t *testing.T, payload []byte, status int) (*module.Manifest, func()) { + t.Helper() + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if status != http.StatusOK { + w.WriteHeader(status) + return + } + _, _ = w.Write(payload) + })) + + manifest := &module.Manifest{ + Name: "demo", + Releases: map[string]module.Release{ + releaseKey(): { + Checksum: checksumOf(payload), + Binary: srv.URL + "/demo", + }, + }, + } + + return manifest, srv.Close +} + +func TestInstallCommandMetadata(t *testing.T) { + c := &InstallCommand{} + if c.Name() != "install" { + t.Fatalf("expected name %q, got %q", "install", c.Name()) + } + if c.Description() == "" { + t.Fatal("expected a non-empty description") + } +} + +func TestInstallExecUsage(t *testing.T) { + c := &InstallCommand{} + + if err := c.Exec(nil); err == nil { + t.Fatal("expected error when no args are given") + } + if err := c.Exec([]string{""}); err == nil { + t.Fatal("expected error for empty module ref") + } + if err := c.Exec([]string{"a", "b"}); err == nil { + t.Fatal("expected error for too many args") + } +} + +func TestDownloadToSuccess(t *testing.T) { + payload := []byte("#!/bin/sh\necho hello\n") + manifest, closeSrv := serveBinary(t, payload, http.StatusOK) + defer closeSrv() + + dir := t.TempDir() + out := captureStdout(t, func() { + if _, err := module.DownloadTo(manifest, dir, testVersion); err != nil { + t.Fatalf("unexpected error: %v", err) + } + }) + + binaryPath := filepath.Join(dir, "demo") + info, err := os.Stat(binaryPath) + if err != nil { + t.Fatalf("expected installed binary, got error: %v", err) + } + + got, err := os.ReadFile(binaryPath) + if err != nil { + t.Fatalf("reading binary: %v", err) + } + if string(got) != string(payload) { + t.Fatalf("binary content mismatch, got %q", string(got)) + } + + if runtime.GOOS != "windows" { + if info.Mode().Perm()&0o111 == 0 { + t.Fatalf("expected binary to be executable, got mode %v", info.Mode().Perm()) + } + } + + if !strings.Contains(out, "Installed") { + t.Fatalf("expected install output, got %q", out) + } +} + +func TestDownloadToCreatesMissingDir(t *testing.T) { + payload := []byte("binary") + manifest, closeSrv := serveBinary(t, payload, http.StatusOK) + defer closeSrv() + + dir := filepath.Join(t.TempDir(), "nested", "gs") + _ = captureStdout(t, func() { + if _, err := module.DownloadTo(manifest, dir, testVersion); err != nil { + t.Fatalf("unexpected error: %v", err) + } + }) + + if _, err := os.Stat(filepath.Join(dir, "demo")); err != nil { + t.Fatalf("expected binary in created dir: %v", err) + } +} + +func TestDownloadToChecksumMismatch(t *testing.T) { + payload := []byte("real content") + manifest, closeSrv := serveBinary(t, payload, http.StatusOK) + defer closeSrv() + + r := manifest.Releases[releaseKey()] + r.Checksum = "deadbeef" + manifest.Releases[releaseKey()] = r + + dir := t.TempDir() + _, err := module.DownloadTo(manifest, dir, testVersion) + if err == nil { + t.Fatal("expected checksum mismatch error, got nil") + } + if !strings.Contains(err.Error(), "checksum mismatch") { + t.Fatalf("expected checksum mismatch error, got %v", err) + } + + if _, statErr := os.Stat(filepath.Join(dir, "demo")); !os.IsNotExist(statErr) { + t.Fatal("expected corrupt binary to be removed") + } +} + +func TestDownloadToAlreadyInstalled(t *testing.T) { + payload := []byte("binary") + manifest, closeSrv := serveBinary(t, payload, http.StatusOK) + defer closeSrv() + + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "demo"), []byte("old"), 0o755); err != nil { + t.Fatalf("seeding existing module: %v", err) + } + + _, err := module.DownloadTo(manifest, dir, testVersion) + if err == nil { + t.Fatal("expected error for already-installed module, got nil") + } + if !strings.Contains(err.Error(), "already installed") { + t.Fatalf("expected already-installed error, got %v", err) + } + + got, err := os.ReadFile(filepath.Join(dir, "demo")) + if err != nil { + t.Fatalf("reading existing binary: %v", err) + } + if string(got) != "old" { + t.Fatalf("expected existing binary to be preserved, got %q", string(got)) + } +} + +func TestDownloadToNoMatchingRelease(t *testing.T) { + manifest := &module.Manifest{ + Name: "demo", + Releases: map[string]module.Release{"9.9.9/nonexistent-os-arch": {}}, + } + + _, err := module.DownloadTo(manifest, t.TempDir(), testVersion) + if err == nil { + t.Fatal("expected error for missing release, got nil") + } + if !strings.Contains(err.Error(), "release") { + t.Fatalf("expected release error, got %v", err) + } +} + +func TestDownloadToNilManifest(t *testing.T) { + _, err := module.DownloadTo(nil, t.TempDir(), testVersion) + if err == nil { + t.Fatal("expected error for nil manifest, got nil") + } +} + +func TestDownloadToNotFound(t *testing.T) { + manifest, closeSrv := serveBinary(t, nil, http.StatusNotFound) + defer closeSrv() + + _, err := module.DownloadTo(manifest, t.TempDir(), testVersion) + if err == nil { + t.Fatal("expected error for missing binary, got nil") + } + if !strings.Contains(err.Error(), "HTTP 404") { + t.Fatalf("expected HTTP 404 error, got %v", err) + } +} diff --git a/internal/cmd/module.go b/internal/cmd/module.go new file mode 100644 index 0000000..7810427 --- /dev/null +++ b/internal/cmd/module.go @@ -0,0 +1,30 @@ +package cmd + +import ( + "fmt" + + "github.com/GoScouter/sdk" +) + +type ModuleCommand struct { + Manager *Manager + Module sdk.Module +} + +func (cmd *ModuleCommand) Name() string { + return cmd.Module.Name() +} + +func (cmd *ModuleCommand) Description() string { + return cmd.Module.Description() +} + +func (cmd *ModuleCommand) Exec(args []string) error { + result, err := cmd.Module.Scout(cmd.Manager.Target, args) + if err != nil { + return err + } + + fmt.Print(result.Render()) + return nil +} diff --git a/internal/cmd/target.go b/internal/cmd/target.go new file mode 100644 index 0000000..cc3e030 --- /dev/null +++ b/internal/cmd/target.go @@ -0,0 +1,42 @@ +package cmd + +import ( + "fmt" + + "goscouter/internal/logger" + "goscouter/internal/style" +) + +type TargetCommand struct { + Manager *Manager +} + +func (cmd *TargetCommand) Name() string { + return "target" +} + +func (cmd *TargetCommand) Description() string { + return "Shows or sets the site that goscouter targets" +} + +func (cmd *TargetCommand) Exec(args []string) error { + if len(args) > 1 { + return fmt.Errorf("usage: target []") + } + + if len(args) == 0 { + fmt.Printf("%s %s\r\n", style.Gray("Target:"), style.Bold(cmd.Manager.Target)) + return nil + } + + target := args[0] + if target == "" { + return fmt.Errorf("usage: target []") + } + + cmd.Manager.SetTarget(target) + + logger.Log.Info(fmt.Sprintf("Target set to %q", target)) + fmt.Printf("%s\r\n", style.Successf("Target set to %s", style.Bold(target))) + return nil +} diff --git a/internal/cmd/target_test.go b/internal/cmd/target_test.go new file mode 100644 index 0000000..fbea65c --- /dev/null +++ b/internal/cmd/target_test.go @@ -0,0 +1,112 @@ +package cmd + +import "testing" + +func TestTargetCommandMetadata(t *testing.T) { + c := &TargetCommand{Manager: &Manager{}} + + if c.Name() != "target" { + t.Fatalf("expected name %q, got %q", "target", c.Name()) + } + if c.Description() == "" { + t.Fatal("expected a non-empty description") + } +} + +func TestTargetCommandRegistered(t *testing.T) { + cm, err := NewManager("example.com", nil) + if err != nil { + t.Fatalf("expected command manager, got error: %v", err) + } + + got, err := cm.Get("target") + if err != nil { + t.Fatalf("expected target command to be registered, got error: %v", err) + } + if _, ok := got.(*TargetCommand); !ok { + t.Fatalf("expected *TargetCommand, got %T", got) + } +} + +func TestNewManagerStoresTarget(t *testing.T) { + cm, err := NewManager("example.com", nil) + if err != nil { + t.Fatalf("expected command manager, got error: %v", err) + } + + if cm.Target != "example.com" { + t.Fatalf("expected target %q, got %q", "example.com", cm.Target) + } +} + +func TestSetTargetUpdatesManager(t *testing.T) { + cm := &Manager{Commands: make(map[string]Command), Target: "old.com"} + + cm.SetTarget("new.com") + + if cm.Target != "new.com" { + t.Fatalf("expected target %q, got %q", "new.com", cm.Target) + } +} + +func TestTargetCommandSetsTarget(t *testing.T) { + cm := &Manager{Commands: make(map[string]Command), Target: "old.com"} + c := &TargetCommand{Manager: cm} + + out := captureStdout(t, func() { + if err := c.Exec([]string{"new.com"}); err != nil { + t.Fatalf("unexpected error: %v", err) + } + }) + + if cm.Target != "new.com" { + t.Fatalf("expected target %q, got %q", "new.com", cm.Target) + } + if out == "" { + t.Fatal("expected confirmation output, got none") + } +} + +func TestTargetCommandShowsCurrentTarget(t *testing.T) { + cm := &Manager{Commands: make(map[string]Command), Target: "example.com"} + c := &TargetCommand{Manager: cm} + + out := captureStdout(t, func() { + if err := c.Exec(nil); err != nil { + t.Fatalf("unexpected error: %v", err) + } + }) + + if cm.Target != "example.com" { + t.Fatalf("expected target to be unchanged, got %q", cm.Target) + } + if out == "" { + t.Fatal("expected target output, got none") + } +} + +func TestTargetCommandUsageErrors(t *testing.T) { + cm := &Manager{Commands: make(map[string]Command), Target: "example.com"} + c := &TargetCommand{Manager: cm} + + if err := c.Exec([]string{""}); err == nil { + t.Fatal("expected error for empty target, got nil") + } + if err := c.Exec([]string{"a", "b"}); err == nil { + t.Fatal("expected error for too many args, got nil") + } + if cm.Target != "example.com" { + t.Fatalf("expected target to be unchanged after errors, got %q", cm.Target) + } +} + +func TestSetTargetIsLiveForCommands(t *testing.T) { + cm := &Manager{Commands: make(map[string]Command), Target: "old.com"} + ext := &ExternalCommand{Manager: cm, ModuleName: "demo", Module: "demo"} + + cm.SetTarget("new.com") + + if ext.Manager.Target != "new.com" { + t.Fatalf("expected external command to see live target %q, got %q", "new.com", ext.Manager.Target) + } +} diff --git a/internal/cmd/uninstall.go b/internal/cmd/uninstall.go new file mode 100644 index 0000000..60706d5 --- /dev/null +++ b/internal/cmd/uninstall.go @@ -0,0 +1,52 @@ +package cmd + +import ( + "fmt" + + "goscouter/internal/logger" + "goscouter/internal/module" + "goscouter/internal/style" +) + +type UninstallCommand struct { + Manager *Manager +} + +func (cmd *UninstallCommand) Name() string { + return "uninstall" +} + +func (cmd *UninstallCommand) Description() string { + return "Uninstalls a module" +} + +func (cmd *UninstallCommand) Exec(args []string) error { + if len(args) != 1 { + return fmt.Errorf("usage: uninstall ") + } + + name := args[0] + if name == "" { + return fmt.Errorf("usage: uninstall ") + } + + logger.Log.Info(fmt.Sprintf("Uninstalling module %q", name)) + + if _, err := module.Remove(name + execSuffix()); err != nil { + return err + } + + return cmd.unregister(name) +} + +func (cmd *UninstallCommand) unregister(name string) error { + if cmd.Manager == nil { + return nil + } + + cmd.Manager.Remove(name) + + fmt.Printf("%s\r\n", style.Successf("Command %s is no longer available", style.Bold(name))) + logger.Log.Info(fmt.Sprintf("Unregistered external command %q", name)) + return nil +} diff --git a/internal/cmd/uninstall_test.go b/internal/cmd/uninstall_test.go new file mode 100644 index 0000000..9d35cce --- /dev/null +++ b/internal/cmd/uninstall_test.go @@ -0,0 +1,142 @@ +package cmd + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "goscouter/internal/module" +) + +func TestUninstallCommandMetadata(t *testing.T) { + c := &UninstallCommand{} + if c.Name() != "uninstall" { + t.Fatalf("expected name %q, got %q", "uninstall", c.Name()) + } + if c.Description() == "" { + t.Fatal("expected a non-empty description") + } +} + +func TestUninstallExecUsage(t *testing.T) { + c := &UninstallCommand{} + + if err := c.Exec(nil); err == nil { + t.Fatal("expected error when no args are given") + } + if err := c.Exec([]string{""}); err == nil { + t.Fatal("expected error for empty module name") + } + if err := c.Exec([]string{"a", "b"}); err == nil { + t.Fatal("expected error for too many args") + } +} + +func TestUninstallCommandUnregisters(t *testing.T) { + cm, err := NewManager("", nil) + if err != nil { + t.Fatalf("expected command manager, got error: %v", err) + } + + cacheDir, err := os.UserCacheDir() + if err != nil { + t.Fatalf("resolving cache dir: %v", err) + } + dir := filepath.Join(cacheDir, "gs") + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatalf("creating cache dir: %v", err) + } + + binaryPath := filepath.Join(dir, "demo-uninstall"+execSuffix()) + if err := os.WriteFile(binaryPath, []byte("binary"), 0o755); err != nil { + t.Fatalf("seeding module binary: %v", err) + } + t.Cleanup(func() { _ = os.Remove(binaryPath) }) + + name := commandName(binaryPath) + cm.Add(&ExternalCommand{ModuleName: name, Module: binaryPath}) + + c := &UninstallCommand{Manager: cm} + out := captureStdout(t, func() { + if err := c.Exec([]string{name}); err != nil { + t.Fatalf("unexpected error: %v", err) + } + }) + + if _, err := os.Stat(binaryPath); !os.IsNotExist(err) { + t.Fatalf("expected binary to be removed, stat err = %v", err) + } + if _, err := cm.Get(name); err == nil { + t.Fatal("expected command to be unregistered") + } + if !strings.Contains(out, "no longer available") { + t.Fatalf("expected uninstall output, got %q", out) + } +} + +func TestUninstallExecNotInstalled(t *testing.T) { + cm, err := NewManager("", nil) + if err != nil { + t.Fatalf("expected command manager, got error: %v", err) + } + + c := &UninstallCommand{Manager: cm} + err = c.Exec([]string{"definitely-not-installed"}) + if err == nil { + t.Fatal("expected error for module that is not installed, got nil") + } + if !strings.Contains(err.Error(), "not installed") { + t.Fatalf("expected not-installed error, got %v", err) + } +} + +func TestRemoveFromSuccess(t *testing.T) { + dir := t.TempDir() + binaryPath := filepath.Join(dir, "demo") + if err := os.WriteFile(binaryPath, []byte("binary"), 0o755); err != nil { + t.Fatalf("seeding module binary: %v", err) + } + + out := captureStdout(t, func() { + got, err := module.RemoveFrom("demo", dir) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != binaryPath { + t.Fatalf("expected removed path %q, got %q", binaryPath, got) + } + }) + + if _, err := os.Stat(binaryPath); !os.IsNotExist(err) { + t.Fatalf("expected binary to be removed, stat err = %v", err) + } + if !strings.Contains(out, "Uninstalled") { + t.Fatalf("expected uninstall output, got %q", out) + } +} + +func TestRemoveFromNotInstalled(t *testing.T) { + _, err := module.RemoveFrom("missing", t.TempDir()) + if err == nil { + t.Fatal("expected error for missing module, got nil") + } + if !strings.Contains(err.Error(), "not installed") { + t.Fatalf("expected not-installed error, got %v", err) + } +} + +func TestRemoveFromRejectsDirectory(t *testing.T) { + dir := t.TempDir() + if err := os.MkdirAll(filepath.Join(dir, "demo"), 0o755); err != nil { + t.Fatalf("creating dir: %v", err) + } + + _, err := module.RemoveFrom("demo", dir) + if err == nil { + t.Fatal("expected error for directory, got nil") + } + if !strings.Contains(err.Error(), "not an installed module") { + t.Fatalf("expected directory rejection error, got %v", err) + } +} diff --git a/internal/dns/dns.go b/internal/dns/dns.go new file mode 100644 index 0000000..bf341be --- /dev/null +++ b/internal/dns/dns.go @@ -0,0 +1,55 @@ +package dns + +import ( + "context" + "fmt" + "net" + "time" + + "goscouter/pkg/records" +) + +const TIMEOUT time.Duration = 5 * time.Second + +func Lookup(host string) (*records.DNSRecords, error) { + ctx, cancel := context.WithTimeout(context.Background(), TIMEOUT) + defer cancel() + + resolver := net.DefaultResolver + records := &records.DNSRecords{Host: host} + + ips, ipErr := resolver.LookupIP(ctx, "ip", host) + for _, ip := range ips { + if ip.To4() != nil { + records.A = append(records.A, ip.String()) + } else { + records.AAAA = append(records.AAAA, ip.String()) + } + } + + if len(ips) == 0 && ipErr != nil { + return nil, fmt.Errorf("could not resolve %s: %w", host, ipErr) + } + + if cname, err := resolver.LookupCNAME(ctx, host); err == nil && cname != host+"." { + records.CNAME = cname + } + + if mx, err := resolver.LookupMX(ctx, host); err == nil { + for _, m := range mx { + records.MX = append(records.MX, fmt.Sprintf("%d %s", m.Pref, m.Host)) + } + } + + if ns, err := resolver.LookupNS(ctx, host); err == nil { + for _, n := range ns { + records.NS = append(records.NS, n.Host) + } + } + + if txt, err := resolver.LookupTXT(ctx, host); err == nil { + records.TXT = txt + } + + return records, nil +} diff --git a/internal/globals.go b/internal/globals.go new file mode 100644 index 0000000..45b2121 --- /dev/null +++ b/internal/globals.go @@ -0,0 +1,6 @@ +package internal + +var ( + BuildTime string + Version string +) diff --git a/internal/logger/log.go b/internal/logger/log.go new file mode 100644 index 0000000..f237611 --- /dev/null +++ b/internal/logger/log.go @@ -0,0 +1,76 @@ +package logger + +import ( + "io" + "log/slog" + "os" + "path/filepath" +) + +type LoggerConfig struct { + Console bool + Level slog.Level +} + +var Log *slog.Logger + +// logFile is the file handle backing Log, retained so it can be closed. +var logFile *os.File + +// Close releases the log file handle. Safe to call when the logger was never +// set up. On Windows an open handle prevents the file from being removed, so +// tests (and any short-lived setup) must call this before cleanup. +func Close() error { + if logFile == nil { + return nil + } + err := logFile.Close() + logFile = nil + return err +} + +func LogPath() (string, error) { + dir, err := os.UserHomeDir() + if err != nil { + return "", err + } + + dir = filepath.Join(dir, "goscouter") + if err := os.MkdirAll(dir, 0755); err != nil { + return "", err + } + + return filepath.Join(dir, "goscouter.log"), nil +} + +func SetupLogger(cfg LoggerConfig) error { + logPath, err := LogPath() + if err != nil { + return err + } + + file, err := os.OpenFile( + logPath, + os.O_CREATE|os.O_WRONLY|os.O_APPEND, + 0644, + ) + if err != nil { + return err + } + + var writer io.Writer = file + if cfg.Console { + writer = io.MultiWriter(os.Stdout, file) + } + + opts := &slog.HandlerOptions{ + Level: cfg.Level, + AddSource: true, + } + + handler := slog.NewTextHandler(writer, opts) + Log = slog.New(handler) + logFile = file + + return nil +} diff --git a/internal/logger/log_test.go b/internal/logger/log_test.go new file mode 100644 index 0000000..c58c78e --- /dev/null +++ b/internal/logger/log_test.go @@ -0,0 +1,57 @@ +package logger + +import ( + "log/slog" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestLogPath(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("USERPROFILE", home) // os.UserHomeDir uses USERPROFILE on Windows + + got, err := LogPath() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if filepath.Base(got) != "goscouter.log" { + t.Fatalf("expected file goscouter.log, got %q", got) + } + if !strings.HasSuffix(filepath.Dir(got), "goscouter") { + t.Fatalf("expected path under goscouter dir, got %q", got) + } + + if info, err := os.Stat(filepath.Dir(got)); err != nil || !info.IsDir() { + t.Fatalf("expected log directory to exist, err=%v", err) + } +} + +func TestSetupLogger(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("USERPROFILE", home) // os.UserHomeDir uses USERPROFILE on Windows + + if err := SetupLogger(LoggerConfig{Console: false, Level: slog.LevelInfo}); err != nil { + t.Fatalf("unexpected error: %v", err) + } + // Windows cannot remove a file while a handle is open; release it before + // t.TempDir's cleanup runs so RemoveAll succeeds. + defer Close() + if Log == nil { + t.Fatal("expected Log to be initialized") + } + + Log.Info("hello from test", "key", "value") + + data, err := os.ReadFile(filepath.Join(home, "goscouter", "goscouter.log")) + if err != nil { + t.Fatalf("reading log file: %v", err) + } + if !strings.Contains(string(data), "hello from test") { + t.Fatalf("expected log message in file, got %q", string(data)) + } +} diff --git a/internal/module/download.go b/internal/module/download.go new file mode 100644 index 0000000..4bccbf5 --- /dev/null +++ b/internal/module/download.go @@ -0,0 +1,122 @@ +package module + +import ( + "crypto/sha256" + "encoding/hex" + "fmt" + "io" + "net/http" + "net/url" + "os" + "path" + "path/filepath" + "runtime" + + "goscouter/internal/logger" +) + +func Download(manifest *Manifest, version string) (string, error) { + if manifest == nil { + return "", fmt.Errorf("module manifest cannot be null") + } + + cacheDir, err := os.UserCacheDir() + if err != nil { + return "", err + } + + return DownloadTo(manifest, filepath.Join(cacheDir, "gs"), version) +} + +func DownloadTo(manifest *Manifest, dir, version string) (string, error) { + if manifest == nil { + return "", fmt.Errorf("module manifest cannot be null") + } + + fmt.Printf("Installing %s@%s\r\n", manifest.Name, version) + logger.Log.Info(fmt.Sprintf("Resolving platform %q for module %s", runtime.GOOS, manifest.Name)) + + key := version + "/" + runtime.GOOS + "-" + runtime.GOARCH + release, ok := manifest.Releases[key] + if !ok { + return "", fmt.Errorf("cannot find matching release (%s)", key) + } + + u, err := url.Parse(release.Binary) + if err != nil { + return "", err + } + + name := path.Base(u.Path) + if name == "" || name == "." || name == "/" { + return "", fmt.Errorf("could not determine a binary name from %q", release.Binary) + } + + if err = os.MkdirAll(dir, 0o755); err != nil { + return "", err + } + + binaryPath := filepath.Join(dir, name) + + if _, err = os.Stat(binaryPath); err == nil { + return "", fmt.Errorf("module %q is already installed at %s", name, binaryPath) + } else if !os.IsNotExist(err) { + return "", err + } + + fmt.Printf("Downloading %s\r\n", release.Binary) + logger.Log.Info(fmt.Sprintf("Downloading binary %q to %s", release.Binary, binaryPath)) + + resp, err := http.Get(release.Binary) + if err != nil { + return "", err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("no binary was found (HTTP %d)", resp.StatusCode) + } + + f, err := os.OpenFile(binaryPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o755) + if err != nil { + return "", err + } + + hasher := sha256.New() + writer := io.MultiWriter(f, hasher) + + written, err := io.Copy(writer, resp.Body) + if err != nil { + // Windows cannot remove a file while a handle is open, so close first. + f.Close() + _ = os.Remove(binaryPath) + return "", err + } + + checksum := hex.EncodeToString(hasher.Sum(nil)) + if checksum != release.Checksum { + f.Close() + _ = os.Remove(binaryPath) + return "", fmt.Errorf( + "checksum mismatch: expected %s, got %s", + release.Checksum, + checksum, + ) + } + + logger.Log.Info(fmt.Sprintf("Verified checksum %s (%d bytes)", checksum, written)) + if err := f.Chmod(0o755); err != nil { + f.Close() + _ = os.Remove(binaryPath) + return "", fmt.Errorf("failed to make binary executable: %w", err) + } + + if err := f.Close(); err != nil { + _ = os.Remove(binaryPath) + return "", err + } + + fmt.Printf("Installed %s (%d bytes) to %s\r\n", manifest.Name, written, binaryPath) + logger.Log.Info(fmt.Sprintf("Installed module %s to %s", manifest.Name, binaryPath)) + return binaryPath, nil +} diff --git a/internal/module/external.go b/internal/module/external.go new file mode 100644 index 0000000..3e4bac8 --- /dev/null +++ b/internal/module/external.go @@ -0,0 +1,60 @@ +package module + +import ( + "fmt" + "os" + "path/filepath" + + "goscouter/internal/logger" + + "github.com/GoScouter/sdk" +) + +func externalDir() (string, error) { + cacheDir, err := os.UserCacheDir() + if err != nil { + return "", err + } + return filepath.Join(cacheDir, "gs"), nil +} + +func LoadExternal() ([]sdk.Module, func(), error) { + dir, err := externalDir() + if err != nil { + return nil, noopCleanup, err + } + + entries, err := os.ReadDir(dir) + if os.IsNotExist(err) { + return nil, noopCleanup, nil + } + if err != nil { + return nil, noopCleanup, err + } + + var mods []sdk.Module + var bins []*sdk.Binary + for _, e := range entries { + if e.IsDir() { + continue + } + + path := filepath.Join(dir, e.Name()) + bin, err := sdk.Open(path) + if err != nil { + logger.Log.Warn(fmt.Sprintf("skipping external module %q: %v", path, err)) + continue + } + mods = append(mods, bin) + bins = append(bins, bin) + } + + cleanup := func() { + for _, b := range bins { + _ = b.Close() + } + } + return mods, cleanup, nil +} + +func noopCleanup() {} diff --git a/internal/module/http.go b/internal/module/http.go new file mode 100644 index 0000000..60423e6 --- /dev/null +++ b/internal/module/http.go @@ -0,0 +1,70 @@ +package module + +import ( + "flag" + "fmt" + "io" + "net/url" + "strings" + + "goscouter/internal/web" + + "github.com/GoScouter/sdk" +) + +type HttpModule struct{} + +func (m *HttpModule) Name() string { + return "http" +} + +func (m *HttpModule) Description() string { + return "Gather the http information of the target domain." +} + +func (m *HttpModule) Version() string { + return "0.0.1" +} + +func (m *HttpModule) Scout(target string, args []string) (sdk.Result, error) { + fs := flag.NewFlagSet("http", flag.ContinueOnError) + fs.SetOutput(io.Discard) + useHTTPS := fs.Bool("ssl", false, "force the https:// scheme on the target") + if err := fs.Parse(args); err != nil { + return nil, err + } + + fmt.Printf("» http: probing %s\r\n", target) + + scheme := "HTTP" + if *useHTTPS { + target = forceScheme(target, "https") + scheme = "HTTPS" + } else { + target = forceScheme(target, "http") + } + + _, err := web.CheckSiteStatus(target) + if err != nil { + return nil, err + } + + records, err := web.FetchHTTPRecords(target, scheme) + if err != nil { + return nil, err + } + + return records, nil +} + +func forceScheme(target, scheme string) string { + if u, err := url.Parse(target); err == nil && u.Host != "" { + u.Scheme = scheme + return u.String() + } + + if i := strings.Index(target, "://"); i >= 0 { + target = target[i+3:] + } + return scheme + "://" + target +} diff --git a/internal/module/manifest.go b/internal/module/manifest.go new file mode 100644 index 0000000..60b87ad --- /dev/null +++ b/internal/module/manifest.go @@ -0,0 +1,92 @@ +package module + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "regexp" + "strings" + + "gopkg.in/src-d/go-git.v4" +) + +type Manifest struct { + Name string `json:"name"` + Releases map[string]Release `json:"releases"` +} + +type Release struct { + Checksum string `json:"sha256"` + Binary string `json:"binary"` +} + +var modulePattern = regexp.MustCompile(`^(?:https?://)?(.+?)@([^@]+)$`) + +type ModuleRef struct { + Git string + Version string +} + +func ParseModule(s string) *ModuleRef { + m := modulePattern.FindStringSubmatch(s) + if m == nil { + return nil + } + + return &ModuleRef{ + Git: m[1], + Version: m[2], + } +} + +func normalizeGitURL(raw string) []string { + if strings.Contains(raw, "://") || strings.Contains(raw, "@") { + return []string{raw} + } + + return []string{ + "https://" + raw, + "http://" + raw, + } +} + +func ResolveManifest(ref *ModuleRef) (*Manifest, error) { + if ref == nil { + return nil, fmt.Errorf("module ref cannot be null") + } + + tempDir, err := os.MkdirTemp("", "repo-*") + if err != nil { + return nil, err + } + defer os.RemoveAll(tempDir) + + for _, url := range normalizeGitURL(ref.Git) { + _, err = git.PlainClone(tempDir, false, &git.CloneOptions{ + URL: url, + }) + + if err == nil { + break + } + } + + if err != nil { + return nil ,err + } + + manifestPath := filepath.Join(tempDir, "manifest.json") + data, err := os.Open(manifestPath) + if err != nil { + return nil, err + } + defer data.Close() + + var manifest Manifest + if err := json.NewDecoder(data).Decode(&manifest); err != nil { + return nil, fmt.Errorf("failed to decode JSON: %v", err) + } + + return &manifest, nil +} diff --git a/internal/module/module.go b/internal/module/module.go new file mode 100644 index 0000000..3b4c556 --- /dev/null +++ b/internal/module/module.go @@ -0,0 +1,39 @@ +package module + +import ( + "fmt" + "maps" + "slices" + + "github.com/GoScouter/sdk" +) + +type Manager struct { + Modules map[string]sdk.Module +} + +func NewManager() *Manager { + m := &Manager{Modules: make(map[string]sdk.Module)} + m.Add(&RecordsModule{}) + m.Add(&SubdomainsModule{}) + m.Add(&HttpModule{}) + m.Add(&ScanModule{Manager: m}) + return m +} + +func (m *Manager) Add(mod sdk.Module) { + m.Modules[mod.Name()] = mod +} + +func (m *Manager) Get(name string) (sdk.Module, error) { + mod, ok := m.Modules[name] + if !ok { + return nil, fmt.Errorf("%s - module does not exist", name) + } + + return mod, nil +} + +func (m *Manager) GetAll() []sdk.Module { + return slices.Collect(maps.Values(m.Modules)) +} diff --git a/internal/module/module_test.go b/internal/module/module_test.go new file mode 100644 index 0000000..123f6e8 --- /dev/null +++ b/internal/module/module_test.go @@ -0,0 +1,97 @@ +package module + +import ( + "testing" + + "github.com/GoScouter/sdk" +) + +func TestManagerRegistersModules(t *testing.T) { + m := NewManager() + + for _, name := range []string{"dns", "subdomains", "http", "scan"} { + mod, err := m.Get(name) + if err != nil { + t.Errorf("Get(%q): unexpected error: %v", name, err) + continue + } + if mod.Name() != name { + t.Errorf("Get(%q).Name() = %q, want %q", name, mod.Name(), name) + } + } +} + +func TestManagerGetUnknown(t *testing.T) { + m := NewManager() + if _, err := m.Get("does-not-exist"); err == nil { + t.Fatal("Get of an unknown module should return an error, got nil") + } +} + +func TestManagerGetAll(t *testing.T) { + m := NewManager() + all := m.GetAll() + if len(all) != 4 { + t.Fatalf("GetAll() returned %d modules, want 4", len(all)) + } +} + +func TestManagerAddOverwrites(t *testing.T) { + m := NewManager() + before := len(m.GetAll()) + m.Add(&HttpModule{}) + if after := len(m.GetAll()); after != before { + t.Errorf("Add of an existing module changed count from %d to %d", before, after) + } +} + +func TestModuleMetadata(t *testing.T) { + cases := []struct { + mod sdk.Module + name string + }{ + {&RecordsModule{}, "dns"}, + {&SubdomainsModule{}, "subdomains"}, + {&HttpModule{}, "http"}, + {&ScanModule{}, "scan"}, + } + for _, c := range cases { + if c.mod.Name() != c.name { + t.Errorf("Name() = %q, want %q", c.mod.Name(), c.name) + } + if c.mod.Description() == "" { + t.Errorf("%s: Description() should not be empty", c.name) + } + if c.mod.Version() == "" { + t.Errorf("%s: Version() should not be empty", c.name) + } + } +} + +func TestForceScheme(t *testing.T) { + cases := []struct { + target string + scheme string + want string + }{ + {"example.com", "https", "https://example.com"}, + {"example.com", "http", "http://example.com"}, + {"http://example.com", "https", "https://example.com"}, + {"https://example.com", "http", "http://example.com"}, + {"http://example.com/path", "https", "https://example.com/path"}, + {"ftp://example.com", "http", "http://example.com"}, + {"example.com:8080", "https", "https://example.com:8080"}, + } + for _, c := range cases { + if got := forceScheme(c.target, c.scheme); got != c.want { + t.Errorf("forceScheme(%q, %q) = %q, want %q", c.target, c.scheme, got, c.want) + } + } +} + +func TestSubdomainResultsRender(t *testing.T) { + var r subdomainResults + if got := r.Render(); got != "" { + t.Errorf("empty subdomainResults.Render() = %q, want empty", got) + } +} diff --git a/internal/module/records.go b/internal/module/records.go new file mode 100644 index 0000000..4309eeb --- /dev/null +++ b/internal/module/records.go @@ -0,0 +1,40 @@ +package module + +import ( + "fmt" + "net/url" + + "goscouter/internal/dns" + + "github.com/GoScouter/sdk" +) + +type RecordsModule struct{} + +func (m *RecordsModule) Name() string { + return "dns" +} + +func (m *RecordsModule) Description() string { + return "Gather the DNS records of the target domain." +} + +func (m *RecordsModule) Version() string { + return "0.0.1" +} + +func (m *RecordsModule) Scout(target string, _ []string) (sdk.Result, error) { + fmt.Printf("» dns: looking up %s\r\n", target) + + parsed, err := url.Parse(target) + if err != nil { + return nil, fmt.Errorf("invalid target %q: %w", target, err) + } + + records, err := dns.Lookup(parsed.Path) + if err != nil { + return nil, err + } + + return records, nil +} diff --git a/internal/module/remove.go b/internal/module/remove.go new file mode 100644 index 0000000..e50c3ad --- /dev/null +++ b/internal/module/remove.go @@ -0,0 +1,42 @@ +package module + +import ( + "fmt" + "os" + "path/filepath" + + "goscouter/internal/logger" +) + +func Remove(name string) (string, error) { + cacheDir, err := os.UserCacheDir() + if err != nil { + return "", err + } + + return RemoveFrom(name, filepath.Join(cacheDir, "gs")) +} + +func RemoveFrom(name, dir string) (string, error) { + binaryPath := filepath.Join(dir, name) + + info, err := os.Stat(binaryPath) + if err != nil { + if os.IsNotExist(err) { + return "", fmt.Errorf("module %q is not installed", name) + } + return "", err + } + + if info.IsDir() { + return "", fmt.Errorf("%q is not an installed module", name) + } + + if err := os.Remove(binaryPath); err != nil { + return "", err + } + + fmt.Printf("Uninstalled %s from %s\r\n", name, binaryPath) + logger.Log.Info(fmt.Sprintf("Removed module %s at %s", name, binaryPath)) + return binaryPath, nil +} diff --git a/internal/module/scan.go b/internal/module/scan.go new file mode 100644 index 0000000..d712126 --- /dev/null +++ b/internal/module/scan.go @@ -0,0 +1,105 @@ +package module + +import ( + "context" + "flag" + "fmt" + "io" + "os" + "strings" + + "goscouter/internal/logger" + "goscouter/internal/scan" + + "github.com/GoScouter/sdk" +) + +type ScanModule struct { + Manager *Manager +} + +var scanExcluded = map[string]bool{ + "subdomains": true, + "scan": true, +} + +func (m *ScanModule) modulesForScan() ([]sdk.Module, func()) { + var mods []sdk.Module + + if m.Manager != nil { + for _, mod := range m.Manager.GetAll() { + if !scanExcluded[mod.Name()] { + mods = append(mods, mod) + } + } + } + + external, cleanup, err := LoadExternal() + if err != nil { + logger.Log.Warn(fmt.Sprintf("scan: loading external modules: %v", err)) + } + for _, mod := range external { + if !scanExcluded[mod.Name()] { + mods = append(mods, mod) + } + } + + return mods, cleanup +} + +func (m *ScanModule) Name() string { + return "scan" +} + +func (m *ScanModule) Description() string { + return "Crawl the target and its subdomains, then render a spider-web graph of DNS/HTTP findings to an HTML page." +} + +func (m *ScanModule) Version() string { + return "0.0.1" +} + +type scanResult struct { + summary scan.Summary + path string +} + +func (r scanResult) Render() string { + var b strings.Builder + b.WriteString("\r\n[SCAN]\r\n") + fmt.Fprintf(&b, " Target : %s\r\n", r.summary.Target) + fmt.Fprintf(&b, " Subdomains : %d discovered\r\n", r.summary.Subdomains) + fmt.Fprintf(&b, " Reachable : %d\r\n", r.summary.Reachable) + fmt.Fprintf(&b, " Graph : %s\r\n", r.path) + b.WriteString("\r\n") + return b.String() +} + +func (m *ScanModule) Scout(target string, args []string) (sdk.Result, error) { + fs := flag.NewFlagSet("scan", flag.ContinueOnError) + fs.SetOutput(io.Discard) + out := fs.String("out", "", "path for the generated HTML graph (default gs-scan-.html)") + if err := fs.Parse(args); err != nil { + return nil, err + } + + mods, cleanup := m.modulesForScan() + defer cleanup() + + graph, err := scan.Build(context.Background(), target, mods) + if err != nil { + return nil, err + } + + html, summary := graph.HTML() + path := *out + if path == "" { + path = fmt.Sprintf("gs-scan-%s.html", summary.Target) + } + + if err := os.WriteFile(path, []byte(html), 0o644); err != nil { + return nil, fmt.Errorf("writing graph to %s: %w", path, err) + } + + return scanResult{summary: summary, path: path}, nil +} diff --git a/internal/module/subdomains.go b/internal/module/subdomains.go new file mode 100644 index 0000000..9ceabfe --- /dev/null +++ b/internal/module/subdomains.go @@ -0,0 +1,53 @@ +package module + +import ( + "context" + "fmt" + "strings" + + "goscouter/internal/net/subdomain" + pkg "goscouter/pkg/subdomains" + + "github.com/GoScouter/sdk" +) + +type SubdomainsModule struct{} + +func (m *SubdomainsModule) Name() string { + return "subdomains" +} + +func (m *SubdomainsModule) Description() string { + return "Gather the subdomains of the target domain." +} + +func (m *SubdomainsModule) Version() string { + return "0.0.1" +} + +type subdomainResults struct { + Subs []pkg.Subdomain +} + +func (r subdomainResults) Render() string { + var b strings.Builder + for _, s := range r.Subs { + b.WriteString(s.Render()) + b.WriteString("\r\n") + } + return b.String() +} + +func (m *SubdomainsModule) Scout(target string, _ []string) (sdk.Result, error) { + fmt.Printf("» subdomains: enumerating %s\r\n", target) + + ctx, cancel := context.WithTimeout(context.Background(), subdomain.TIMEOUT) + defer cancel() + + subdomains, err := subdomain.FindAll(ctx, target) + if err != nil { + return nil, err + } + + return subdomainResults{Subs: subdomains}, nil +} diff --git a/internal/net/subdomain/finder.go b/internal/net/subdomain/finder.go new file mode 100644 index 0000000..b34f01d --- /dev/null +++ b/internal/net/subdomain/finder.go @@ -0,0 +1,192 @@ +package subdomain + +import ( + "bufio" + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/url" + "sort" + "strings" + "sync" + "time" + + "goscouter/pkg/subdomains" +) + +type Finder struct { + Name string + Fetch func(ctx context.Context, domain string) ([]subdomains.Subdomain, error) +} + +var Finders = map[string]Finder{ + "crtsh": {Name: "crtsh", Fetch: fetchCrtSh}, + "certspotter": {Name: "certspotter", Fetch: fetchCertSpotter}, +} + +const TIMEOUT time.Duration = 5 * time.Second + +// crt.sh timestamps carry no timezone, e.g. "2022-12-01T00:00:00". +const crtShTimeLayout = "2006-01-02T15:04:05" + +func normalize(n string) string { + n = strings.ToLower(strings.TrimSpace(n)) + n = strings.TrimPrefix(n, "*.") + return n +} + +func keepLatest(latest map[string]time.Time, name string, t time.Time) { + name = normalize(name) + if name == "" { + return + } + + if prev, ok := latest[name]; !ok || t.After(prev) { + latest[name] = t + } +} + +func flatten(latest map[string]time.Time) []subdomains.Subdomain { + out := make([]subdomains.Subdomain, 0, len(latest)) + for name, t := range latest { + out = append(out, subdomains.Subdomain{Name: name, LastSeen: t}) + } + sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name }) + return out +} + +func newRequest(ctx context.Context, rawURL string) (*http.Request, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil) + if err != nil { + return nil, err + } + req.Header.Set("User-Agent", "goscouter/1.0") + req.Header.Set("Accept", "application/json") + return req, nil +} + +func fetchCrtSh(ctx context.Context, domain string) ([]subdomains.Subdomain, error) { + q := url.QueryEscape("%." + domain) + rawURL := fmt.Sprintf("https://crt.sh/?q=%s&output=json", q) + + req, err := newRequest(ctx, rawURL) + if err != nil { + return nil, err + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("crt.sh returned status %d", resp.StatusCode) + } + + var entries []struct { + NameValue string `json:"name_value"` + CommonName string `json:"common_name"` + NotBefore string `json:"not_before"` + } + if err := json.NewDecoder(resp.Body).Decode(&entries); err != nil { + return nil, fmt.Errorf("decoding crt.sh response: %w", err) + } + + latest := make(map[string]time.Time) + for _, e := range entries { + t, _ := time.Parse(crtShTimeLayout, e.NotBefore) + for _, n := range strings.Split(e.NameValue, "\n") { + keepLatest(latest, n, t) + } + if e.CommonName != "" { + keepLatest(latest, e.CommonName, t) + } + } + return flatten(latest), nil +} + +func fetchCertSpotter(ctx context.Context, domain string) ([]subdomains.Subdomain, error) { + rawURL := fmt.Sprintf( + "https://api.certspotter.com/v1/issuances?domain=%s&include_subdomains=true&expand=dns_names", + url.QueryEscape(domain), + ) + + req, err := newRequest(ctx, rawURL) + if err != nil { + return nil, err + } + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body := bufio.NewReader(resp.Body) + line, _ := body.ReadString('\n') + return nil, fmt.Errorf("certspotter returned status %d: %s", resp.StatusCode, strings.TrimSpace(line)) + } + + var issuances []struct { + DNSNames []string `json:"dns_names"` + NotBefore string `json:"not_before"` + } + if err := json.NewDecoder(resp.Body).Decode(&issuances); err != nil { + return nil, fmt.Errorf("decoding certspotter response: %w", err) + } + + latest := make(map[string]time.Time) + for _, iss := range issuances { + t, _ := time.Parse(time.RFC3339, iss.NotBefore) + for _, n := range iss.DNSNames { + keepLatest(latest, n, t) + } + } + return flatten(latest), nil +} + +type finderResult struct { + source string + subs []subdomains.Subdomain + err error +} + +func FindAll(ctx context.Context, domain string) ([]subdomains.Subdomain, error) { + ch := make(chan finderResult, len(Finders)) + var wg sync.WaitGroup + + for _, f := range Finders { + wg.Add(1) + go func(f Finder) { + defer wg.Done() + subs, err := f.Fetch(ctx, domain) + ch <- finderResult{source: f.Name, subs: subs, err: err} + }(f) + } + + go func() { + wg.Wait() + close(ch) + }() + + latest := make(map[string]time.Time) + var errs []error + for res := range ch { + if res.err != nil { + errs = append(errs, fmt.Errorf("%s: %w", res.source, res.err)) + continue + } + for _, s := range res.subs { + keepLatest(latest, s.Name, s.LastSeen) + } + } + + subs := flatten(latest) + if len(subs) == 0 && len(errs) > 0 { + return nil, fmt.Errorf("all finders failed: %w", errors.Join(errs...)) + } + + return subs, nil +} diff --git a/internal/net/subdomain/finder_test.go b/internal/net/subdomain/finder_test.go new file mode 100644 index 0000000..39fd927 --- /dev/null +++ b/internal/net/subdomain/finder_test.go @@ -0,0 +1,98 @@ +package subdomain + +import ( + "testing" + "time" +) + +func TestNormalize(t *testing.T) { + cases := []struct { + in string + want string + }{ + {"Example.com", "example.com"}, + {" api.example.com ", "api.example.com"}, + {"*.example.com", "example.com"}, + {"*.API.Example.COM", "api.example.com"}, + {"", ""}, + {" ", ""}, + } + for _, c := range cases { + if got := normalize(c.in); got != c.want { + t.Errorf("normalize(%q) = %q, want %q", c.in, got, c.want) + } + } +} + +func TestKeepLatestKeepsNewer(t *testing.T) { + latest := make(map[string]time.Time) + older := time.Date(2022, 1, 1, 0, 0, 0, 0, time.UTC) + newer := time.Date(2023, 1, 1, 0, 0, 0, 0, time.UTC) + + keepLatest(latest, "api.example.com", older) + keepLatest(latest, "api.example.com", newer) + + if got := latest["api.example.com"]; !got.Equal(newer) { + t.Errorf("keepLatest kept %v, want the newer %v", got, newer) + } +} + +func TestKeepLatestIgnoresOlder(t *testing.T) { + latest := make(map[string]time.Time) + older := time.Date(2022, 1, 1, 0, 0, 0, 0, time.UTC) + newer := time.Date(2023, 1, 1, 0, 0, 0, 0, time.UTC) + + keepLatest(latest, "api.example.com", newer) + keepLatest(latest, "api.example.com", older) + + if got := latest["api.example.com"]; !got.Equal(newer) { + t.Errorf("keepLatest kept %v, want the newer %v", got, newer) + } +} + +func TestKeepLatestNormalizesKey(t *testing.T) { + latest := make(map[string]time.Time) + t0 := time.Date(2023, 1, 1, 0, 0, 0, 0, time.UTC) + + keepLatest(latest, "*.API.Example.com", t0) + + if _, ok := latest["api.example.com"]; !ok { + t.Errorf("keepLatest should store the normalized key, got keys: %v", latest) + } +} + +func TestKeepLatestSkipsEmptyName(t *testing.T) { + latest := make(map[string]time.Time) + keepLatest(latest, " ", time.Date(2023, 1, 1, 0, 0, 0, 0, time.UTC)) + if len(latest) != 0 { + t.Errorf("keepLatest should skip empty names, got: %v", latest) + } +} + +func TestFlattenSortsByName(t *testing.T) { + t0 := time.Date(2023, 1, 1, 0, 0, 0, 0, time.UTC) + latest := map[string]time.Time{ + "c.example.com": t0, + "a.example.com": t0, + "b.example.com": t0, + } + + out := flatten(latest) + + if len(out) != 3 { + t.Fatalf("flatten returned %d entries, want 3", len(out)) + } + want := []string{"a.example.com", "b.example.com", "c.example.com"} + for i, w := range want { + if out[i].Name != w { + t.Errorf("flatten[%d].Name = %q, want %q", i, out[i].Name, w) + } + } +} + +func TestFlattenEmpty(t *testing.T) { + out := flatten(map[string]time.Time{}) + if len(out) != 0 { + t.Errorf("flatten of empty map returned %d entries, want 0", len(out)) + } +} diff --git a/internal/scan/graph.go b/internal/scan/graph.go new file mode 100644 index 0000000..5dc1724 --- /dev/null +++ b/internal/scan/graph.go @@ -0,0 +1,30 @@ +package scan + +type ModuleResult struct { + Module string `json:"module"` + Output string `json:"output,omitempty"` + Err string `json:"err,omitempty"` +} + +type HostReport struct { + Host string `json:"host"` + Results []ModuleResult `json:"results,omitempty"` +} + +func (r HostReport) Reachable() bool { + for _, m := range r.Results { + if m.Err == "" && m.Output != "" { + return true + } + } + return false +} + +type Node struct { + Report HostReport `json:"report"` + Children []*Node `json:"children,omitempty"` +} + +type Graph struct { + Root *Node `json:"root"` +} diff --git a/internal/scan/html.go b/internal/scan/html.go new file mode 100644 index 0000000..62673c3 --- /dev/null +++ b/internal/scan/html.go @@ -0,0 +1,253 @@ +package scan + +import ( + "encoding/json" + "strings" +) + +type Summary struct { + Target string + Subdomains int + Reachable int +} + +type node struct { + ID string `json:"id"` + Root bool `json:"root"` + Reachable bool `json:"reachable"` + Report HostReport `json:"report"` +} + +type link struct { + Source string `json:"source"` + Target string `json:"target"` +} + +type payload struct { + Target string `json:"target"` + Nodes []node `json:"nodes"` + Links []link `json:"links"` +} + +func (g *Graph) HTML() (string, Summary) { + p := payload{} + sum := Summary{} + + if g.Root != nil { + p.Target = g.Root.Report.Host + sum.Target = g.Root.Report.Host + + p.Nodes = append(p.Nodes, node{ + ID: g.Root.Report.Host, + Root: true, + Reachable: g.Root.Report.Reachable(), + Report: g.Root.Report, + }) + + for _, c := range g.Root.Children { + sum.Subdomains++ + if c.Report.Reachable() { + sum.Reachable++ + } + p.Nodes = append(p.Nodes, node{ + ID: c.Report.Host, + Reachable: c.Report.Reachable(), + Report: c.Report, + }) + p.Links = append(p.Links, link{Source: g.Root.Report.Host, Target: c.Report.Host}) + } + } + + data, err := json.Marshal(p) + if err != nil { + data = []byte("null") + } + + html := strings.Replace(pageTemplate, "/*__GRAPH_DATA__*/", string(data), 1) + return html, sum +} + +const pageTemplate = ` + + + + +GoScouter scan + + + +
+
+ +
GoScouter scan
+
+ reachable + no response +
+
+ +
+ + + +` diff --git a/internal/scan/scan.go b/internal/scan/scan.go new file mode 100644 index 0000000..466e6cf --- /dev/null +++ b/internal/scan/scan.go @@ -0,0 +1,100 @@ +package scan + +import ( + "context" + "fmt" + "net/url" + "sort" + "strings" + "sync" + + "goscouter/internal/net/subdomain" + + "github.com/GoScouter/sdk" +) + +// maxConcurrentProbes bounds how many hosts are probed at once. There is no cap +// on how many subdomains get scanned, only on simultaneous in-flight hosts so +// a domain with hundreds of subdomains does not spawn hundreds of module runs +// at once. Modules run sequentially within a single host. +const maxConcurrentProbes = 12 + +func hostOf(target string) string { + t := strings.TrimSpace(target) + t = strings.TrimPrefix(t, "*.") + + if strings.Contains(t, "://") { + if u, err := url.Parse(t); err == nil && u.Hostname() != "" { + return u.Hostname() + } + } + + // No scheme: url.Parse would shove everything into Path, so parse by hand. + if i := strings.IndexAny(t, "/?#"); i >= 0 { + t = t[:i] + } + if h, _, ok := strings.Cut(t, ":"); ok { + t = h + } + return t +} + +func Build(ctx context.Context, target string, mods []sdk.Module) (*Graph, error) { + host := hostOf(target) + + fmt.Printf("» scan: discovering subdomains for %s\r\n", host) + subs, _ := subdomain.FindAll(ctx, host) + fmt.Printf("» scan: found %d subdomains, probing hosts\r\n", len(subs)) + root := &Node{Report: probeHost(host, mods)} + + sem := make(chan struct{}, maxConcurrentProbes) + var wg sync.WaitGroup + var mu sync.Mutex + + for _, s := range subs { + name := hostOf(s.Name) + if name == "" || name == host { + continue + } + + wg.Add(1) + go func(name string) { + defer wg.Done() + sem <- struct{}{} + defer func() { <-sem }() + + child := &Node{Report: probeHost(name, mods)} + mu.Lock() + root.Children = append(root.Children, child) + mu.Unlock() + }(name) + } + wg.Wait() + + sort.Slice(root.Children, func(i, j int) bool { + return root.Children[i].Report.Host < root.Children[j].Report.Host + }) + + return &Graph{Root: root}, nil +} + +func probeHost(host string, mods []sdk.Module) HostReport { + fmt.Printf("» scan: probing %s\r\n", host) + report := HostReport{Host: host} + + for _, mod := range mods { + result := ModuleResult{Module: mod.Name()} + if res, err := mod.Scout(host, nil); err != nil { + result.Err = err.Error() + } else { + result.Output = res.Render() + } + report.Results = append(report.Results, result) + } + + sort.Slice(report.Results, func(i, j int) bool { + return report.Results[i].Module < report.Results[j].Module + }) + + return report +} diff --git a/internal/scan/scan_test.go b/internal/scan/scan_test.go new file mode 100644 index 0000000..bd40c66 --- /dev/null +++ b/internal/scan/scan_test.go @@ -0,0 +1,94 @@ +package scan + +import ( + "strings" + "testing" +) + +func TestHostOf(t *testing.T) { + cases := map[string]string{ + "https://example.com/path": "example.com", + "http://example.com": "example.com", + "example.com": "example.com", + "example.com:8443": "example.com", + "example.com/a/b?q=1": "example.com", + "*.example.com": "example.com", + " https://EXAMPLE.com ": "EXAMPLE.com", + } + for in, want := range cases { + if got := hostOf(in); got != want { + t.Errorf("hostOf(%q) = %q, want %q", in, got, want) + } + } +} + +func TestReachable(t *testing.T) { + if (HostReport{Results: []ModuleResult{{Module: "http", Err: "x"}, {Module: "dns", Err: "y"}}}).Reachable() { + t.Error("host where every module errored should not be reachable") + } + if (HostReport{Results: []ModuleResult{{Module: "http"}}}).Reachable() { + t.Error("host with only empty output should not be reachable") + } + if !(HostReport{Results: []ModuleResult{{Module: "http", Output: "200 OK"}}}).Reachable() { + t.Error("host with module output should be reachable") + } +} + +func sampleGraph() *Graph { + return &Graph{Root: &Node{ + Report: HostReport{ + Host: "example.com", + Results: []ModuleResult{ + {Module: "dns", Output: "A 93.184.216.34"}, + {Module: "http", Output: "200 OK"}, + }, + }, + Children: []*Node{ + {Report: HostReport{ + Host: "api.example.com", + Results: []ModuleResult{{Module: "http", Output: "200 OK"}}, + }}, + {Report: HostReport{ + Host: "dead.example.com", + Results: []ModuleResult{{Module: "http", Err: "connection refused"}}, + }}, + }, + }} +} + +func TestHTMLSummary(t *testing.T) { + html, sum := sampleGraph().HTML() + + if sum.Target != "example.com" { + t.Errorf("Target = %q, want example.com", sum.Target) + } + if sum.Subdomains != 2 { + t.Errorf("Subdomains = %d, want 2", sum.Subdomains) + } + if sum.Reachable != 1 { + t.Errorf("Reachable = %d, want 1 (api reachable, dead not)", sum.Reachable) + } + + if !strings.Contains(html, "") + } + for _, host := range []string{"example.com", "api.example.com", "dead.example.com"} { + if !strings.Contains(html, host) { + t.Errorf("HTML missing host %q in embedded data", host) + } + } + // The embedded JSON must not break out of the ") != true { + t.Error("template should contain a closing tag") + } + if idx := strings.Index(html, "const DATA ="); idx >= 0 { + data := html[idx:] + end := strings.Index(data, "") + if end < 0 { + t.Fatal("no after DATA") + } + if strings.Contains(data[:end], "") { + t.Error("embedded graph data breaks out of the