commit 981587e83d93d3c7aa9689121b0b5b2a9148c8ae Author: Owen Qwen Date: Sun Jun 28 16:57:34 2026 -0500 Initial release: self-hostable APT repository server and CLI urapt is a self-hostable APT repository server with a companion CLI for pushing and managing Debian .deb packages. Server (urapt-server): - REST API + APT endpoint, SQLite storage (pure-Go modernc driver, no CGO) - .deb files stored content-addressed on disk, reference-counted for dedup - Server-managed RSA-4096 OpenPGP signing key (ProtonMail/go-crypto) - APT indices (Release/InRelease/Packages[.gz/.xz]) generated on demand from the DB, cached in memory, signed with the server key - Full APT model: repositories -> distributions -> components -> architectures - Bearer-token auth for REST; HTTP Basic auth for private-repo APT reads - First registrant becomes admin; repo-scoped permissions (read/write/read-write/admin) plus owner and server-admin roles - Multipart package push with control-field extraction, list/show/delete, pool serving, blob ref-count cleanup - Audit log CLI (urapt): - register/login/logout/whoami, token management - repo/distro/component/arch CRUD, member management - push/pull/ls/show/rm for packages - apt-config helper that emits apt setup commands (key, sources.list, auth.conf for private repos) Packaging & docs: - Dockerfile (multi-stage distroless), docker-compose.yml, sample config - README quick start, architecture overview, config reference, security notes - PLAN.md design blueprint, CHANGELOG.md, GPL-3.0 LICENSE - GitHub Actions CI (test, lint, cross-build for linux/darwin amd64/arm64) - Makefile release target producing static binaries + tarballs + checksums Tests cover the data-access layer, auth/permission checks, APT index generation, .deb parsing, GPG signing, the REST API, and the typed API client. Verified end-to-end on a Raspberry Pi (arm64) pushing and installing a real package. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..ca7963e --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,83 @@ +name: CI + +on: + push: + branches: [main, master] + tags: ['v*'] + pull_request: + +permissions: + contents: read + +jobs: + test: + name: Test (linux/amd64) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version: '1.26' + cache: true + - name: gofmt + run: | + unformatted="$(gofmt -l .)" + if [ -n "$unformatted" ]; then + echo "::error::gofmt would modify the following files:" + echo "$unformatted" + gofmt -d $unformatted + exit 1 + fi + - name: go vet + run: go vet ./... + - name: go build + run: go build ./... + - name: go test + run: go test -count=1 ./... + + lint: + name: Lint + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version: '1.26' + cache: true + - uses: golangci/golangci-lint-action@v6 + with: + version: latest + + cross-build: + name: Cross-build (${{ matrix.goos }}/${{ matrix.goarch }}) + 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 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version: '1.26' + cache: true + - name: Build urapt-server + env: + GOOS: ${{ matrix.goos }} + GOARCH: ${{ matrix.goarch }} + CGO_ENABLED: '0' + run: go build -o /dev/null ./cmd/urapt-server + - name: Build urapt CLI + env: + GOOS: ${{ matrix.goos }} + GOARCH: ${{ matrix.goarch }} + CGO_ENABLED: '0' + run: go build -o /dev/null ./cmd/urapt diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f3d53c2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,24 @@ +# Build output +/urapt +/urapt-server +/bin/ +/dist/ + +# Runtime data +/store/ +*.db +*.db-wal +*.db-shm + +# Test artifacts +/hello/ +/hello.deb + +# Go +vendor/ + +# Editor / OS +.DS_Store +*.swp +.idea/ +.vscode/ diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..c10fdb6 --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,21 @@ +run: + timeout: 5m + go: "1.22" + +linters: + disable-all: true + enable: + - errcheck + - govet + - ineffassign + - staticcheck + - unused + - misspell + - revive + +issues: + exclude-rules: + - path: _test\.go + linters: + - errcheck + - revive diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..b9ef14c --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,62 @@ +# Changelog + +All notable changes to urapt will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +## [0.1.0] - 2026-06-28 + +First public release. urapt is a self-hostable APT repository server with a +companion CLI for pushing and managing Debian `.deb` packages. + +### Added +- **Server** (`urapt-server`): REST API + APT endpoint server. + - SQLite storage (pure-Go `modernc.org/sqlite`, no CGO) with embedded + migrations. Only `.deb` files are stored on disk, content-addressed by + SHA-256 and reference-counted for deduplication. + - Server-managed RSA-4096 OpenPGP signing key (`ProtonMail/go-crypto`), + generated on first run and stored armored in the database. + - APT indices (`Release`, `Release.gpg`, `InRelease`, `Packages` with + `.gz`/`.xz` compression) generated on demand from the database and cached + in memory; never written to disk. + - Full APT model: repositories → distributions → components → architectures. + - Bearer-token auth for the REST API; HTTP Basic auth (password = API token) + for private-repo APT reads. Public repos allow anonymous APT reads. + - First registrant becomes admin; repo-scoped permissions + (`read`/`write`/`read-write`/`admin`) plus owner and server-admin roles. + - Multipart package push with control-field extraction, list/show/delete, + pool serving, and blob ref-count cleanup on delete. + - Audit log for mutating actions. +- **CLI** (`urapt`): companion tool for pushing packages and managing repos. + - `register`, `login`, `logout`, `whoami`, `token` (create/list/revoke). + - `repo` (create/list/show/update/delete), `member` (add/update/remove/list). + - `distro`, `component`, `arch` CRUD. + - `push`, `pull`, `ls`, `show`, `rm` for packages. + - `apt-config` helper that prints the exact `apt` setup commands (key + install, sources.list entry, and auth.conf snippet for private repos). +- **Packaging**: `Dockerfile` (multi-stage distroless static build), + `docker-compose.yml`, sample `urapt-server.toml.example`. +- **Docs**: `README.md` quick start, architecture overview, configuration + reference, and security notes; `PLAN.md` full design blueprint. + +### Security +- Passwords are bcrypt-hashed (cost 12). API tokens are random 32-byte values + stored only as SHA-256 hashes with a short display prefix; revocable. +- The OpenPGP private signing key is stored unencrypted in the SQLite + database. This is acceptable when you control the database file; for + stronger protection, restrict file permissions and back up the DB securely. + Per-repo keys and key encryption-at-rest are planned. +- For internet-facing deployments, run behind a TLS-terminating reverse proxy + (Caddy/nginx). Private-repo credentials must never travel over plain HTTP. + +### Known Limitations +- Single server-managed signing key (no per-repo keys yet). +- No web UI; all management is via the CLI. +- No rate limiting or brute-force protection on login endpoints. +- No source packages (`.dsc`/`.orig.tar.*`) or AppStream metadata. + +[Unreleased]: https://github.com/owen/urapt/compare/v0.1.0...HEAD +[0.1.0]: https://github.com/owen/urapt/releases/tag/v0.1.0 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..a5813c2 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,19 @@ +# syntax=docker/dockerfile:1 + +FROM golang:1.22-bookworm AS build +WORKDIR /src +COPY go.mod go.sum ./ +RUN go mod download +COPY . . +RUN CGO_ENABLED=0 go build -ldflags="-s -w" -o /out/urapt-server ./cmd/urapt-server \ + && CGO_ENABLED=0 go build -ldflags="-s -w" -o /out/urapt ./cmd/urapt + +FROM gcr.io/distroless/static-debian12:nonroot +COPY --from=build /out/urapt-server /usr/local/bin/urapt-server +EXPOSE 8080 +VOLUME ["/data"] +ENV URAPT_STORE_DIR=/data/store +ENV URAPT_BASE_URL=http://localhost:8080 +USER nonroot:nonroot +ENTRYPOINT ["urapt-server"] +CMD ["--bind", "0.0.0.0:8080"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..f921c48 --- /dev/null +++ b/LICENSE @@ -0,0 +1,692 @@ +urapt - a self-hostable APT repository server. +Copyright (C) 2026 urapt contributors + +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 . + +--- + + 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 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 or 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 or 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..5e3fec7 --- /dev/null +++ b/Makefile @@ -0,0 +1,69 @@ +BINARY_SERVER := urapt-server +BINARY_CLI := urapt +GO := go +LDFLAGS := -s -w + +# Version is injected into the binary via -X. Falls back to the git describe +# output (tag or commit), then to "dev" when not in a git checkout. +VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo dev) +RELEASE_LDFLAGS := -s -w -X urapt/shared/version.Version=$(VERSION) + +# Release targets: -. Windows is omitted (no apt client there). +RELEASE_TARGETS := linux-amd64 linux-arm64 darwin-amd64 darwin-arm64 + +.PHONY: all build build-server build-cli test vet fmt lint run-server run-cli clean tidy release release-binaries checksums + +all: build + +build: build-server build-cli + +build-server: + $(GO) build -ldflags "$(LDFLAGS)" -o $(BINARY_SERVER) ./cmd/urapt-server + +build-cli: + $(GO) build -ldflags "$(LDFLAGS)" -o $(BINARY_CLI) ./cmd/urapt + +test: + $(GO) test ./... + +vet: + $(GO) vet ./... + +fmt: + $(GO) fmt ./... + +lint: vet + @command -v golangci-lint >/dev/null 2>&1 && golangci-lint run ./... || echo "golangci-lint not installed; skipping" + +run-server: build-server + ./$(BINARY_SERVER) + +run-cli: build-cli + ./$(BINARY_CLI) + +tidy: + $(GO) mod tidy + +clean: + rm -f $(BINARY_SERVER) $(BINARY_CLI) + rm -rf bin/ dist/ + +# release builds static, CGO-free binaries for all release targets into dist/, +# bundles each pair (server + CLI) into a tar.gz, and produces a checksums file. +release: release-binaries checksums + +release-binaries: + @mkdir -p dist + @for target in $(RELEASE_TARGETS); do \ + os=$${target%-*}; \ + arch=$${target#*-}; \ + echo "==> building $$os/$$arch"; \ + GOOS=$$os GOARCH=$$arch CGO_ENABLED=0 $(GO) build -trimpath -ldflags "$(RELEASE_LDFLAGS)" -o dist/$(BINARY_SERVER)-$$os-$$arch ./cmd/urapt-server; \ + GOOS=$$os GOARCH=$$arch CGO_ENABLED=0 $(GO) build -trimpath -ldflags "$(RELEASE_LDFLAGS)" -o dist/$(BINARY_CLI)-$$os-$$arch ./cmd/urapt; \ + tar -czf dist/urapt-$(VERSION)-$$os-$$arch.tar.gz -C dist $(BINARY_SERVER)-$$os-$$arch $(BINARY_CLI)-$$os-$$arch; \ + done + @echo "==> release artifacts in dist/" + +checksums: + @cd dist && sha256sum *.tar.gz > checksums-$(VERSION).txt + @echo "==> wrote dist/checksums-$(VERSION).txt" diff --git a/PLAN.md b/PLAN.md new file mode 100644 index 0000000..e5dd23f --- /dev/null +++ b/PLAN.md @@ -0,0 +1,979 @@ +# urapt — Implementation Plan + +A self-hostable APT repository server with a companion CLI for pushing and +managing Debian/Ubuntu `.deb` packages under your logged-in user. + +> Status: Planning. This document is the authoritative blueprint for +> implementation. Decisions captured here are final unless explicitly marked +> "open" or "future". + +--- + +## 1. Goals & scope + +### In scope (v1) +- A self-hostable **server** that: + - Exposes a **REST API** for the CLI to manage repositories, users, + distributions, components, architectures, and packages. + - Exposes a **special APT endpoint** that the standard `apt` client speaks to + (serves `dists/.../{Release,InRelease,Release.gpg}`, `Packages` indices, and + the `pool/` `.deb` files). + - Stores **only uploaded `.deb` files on the filesystem** (`store/packages/`). + Everything else lives in a **SQLite database** (`store/database/sqlite.db`). + - Generates APT indices **on demand from the database** (indices are never + persisted to disk). + - Signs `Release`/`InRelease` with a **server-managed GPG key**. +- A **CLI** (`urapt`) that: + - Logs you in (username/password → API token stored locally). + - Pushes `.deb` packages to a repository/distribution/component under your + identity. + - Pulls, lists, and deletes packages. + - Manages repositories, members, distributions, components, architectures. + - Emits the `sources.list` line + pubkey + `auth.conf` for client setup. +- A **shared utilities component** (`shared/`) used by both server and CLI: + config, DB, models, GPG, `.deb` parsing, APT index generation, crypto, and + the typed REST API client + DTOs. + +### Out of scope (v1, listed as future) +- Source package (`deb-src`) hosting. +- AppStream / `dep11` metadata. +- `Acquire-By-Hash` indices. +- Web UI. +- External OAuth/OIDC auth. +- Per-repository GPG keys (v1 uses one server-wide key). +- OS keychain credential storage (v1 stores token in a 0600 config file). +- Multi-arch `Contents` indexes. + +--- + +## 2. Confirmed decisions + +| Decision | Choice | +|---|---| +| Language / stack | **Go** — single static binary for both server and CLI | +| GPG signing | **Server-managed key**: server generates & stores its own key, signs `Release`/`InRelease` automatically; admin exports the pubkey for clients | +| Auth (CLI ↔ API) | **API tokens**: user logs in with username/password once, gets a token stored locally; CLI sends token in `Authorization: Bearer` | +| Bootstrap admin | **First user to register becomes admin** | +| Repo model | **Full model**: repositories contain suites/distributions (stable, testing…), each suite has components (main, contrib…) and architectures (amd64, arm64, all) | +| Access policy | **Public repo**: APT read is unauthenticated, REST write requires auth. **Private repo**: APT read *and* REST write require auth (APT read via HTTP Basic with token) | +| Permissions | **Repository-scoped**. Creator = owner with full read/write. Owner can grant `read` / `write` / `read-write` / `admin` to other users. Only users with access can push. Server admins can manage everything | + +--- + +## 3. Architecture overview + +Three components, one Go module (`urapt`): + +``` + +-------------------+ +-------------------+ + | urapt (CLI) | | urapt-server | + | cmd/urapt | | cmd/urapt-server | + +---------+---------+ +---------+---------+ + | | + | uses shared/ | uses shared/ + v v + +---------------------------------------------+ + | shared/ | + | config | db | models | gpg | deb | apt | | + | crypto | api(types) | apiclient(HTTP) | | + +---------------------------------------------+ + | | + +------> SQLite <-----+ | + | | + store/database/sqlite.db + store/packages/*.deb (files only) +``` + +- The **CLI** never touches the DB or filesystem directly; it only talks to the + server's REST API via `shared/apiclient`. +- The **server** owns the DB and the `store/` directory. +- The **shared** component contains pure libraries and the API contract. No + component imports upward (no `shared` → `server` or `shared` → `cli`). + +--- + +## 4. Project structure + +``` +urapt/ + go.mod module path: urapt + go.sum + README.md + PLAN.md + .gitignore ignores store/, *.db, built binaries + Dockerfile multi-stage build for urapt-server + docker-compose.yml example self-hosted deployment + Makefile build/test/lint targets + + cmd/ + urapt-server/main.go server entrypoint: load config, wire app, run + urapt/ main.go CLI entrypoint: execute cobra root + + shared/ ---- shared utilities component ---- + config/ config structs + file/env/flag loading + db/ sqlite open (WAL), migrations runner, query helpers + models/ domain types (User, Repository, Package, …) + api/ REST DTOs + request/response shapes (the contract) + apiclient/ typed HTTP client used by the CLI + crypto/ password hashing (bcrypt), token gen + hashing + gpg/ key generation, clearsign, detached sign, export + deb/ .deb (ar) unpack, control.tar parse, control fields + apt/ index generation: Packages, Release, InRelease, Release.gpg + httputil/ JSON helpers, error rendering, bearer/basic parsing + log/ structured logging wrapper + version/ build version info + + server/ ---- server component ---- + app/ wiring: dependencies, store paths, startup, key init + restapi/ REST handlers + routes (chi) + aptrepo/ APT endpoint handlers + index cache + middleware/ auth (bearer), basic-auth (for private APT), logging, recover + auth/ token resolution, permission checks, session/identity + + cli/ ---- CLI component ---- + commands/ cobra commands (login, push, repo, …) + config/ local config + token store (~/.config/urapt) + output/ table/JSON formatting helpers + interact/ prompts (password, confirm) + + migrations/ numbered *.sql files, embedded via go:embed + 0001_init.sql + + store/ runtime data (gitignored, created at runtime) + database/sqlite.db + packages/.deb +``` + +### Import rules +- `shared/**` imports only stdlib + 3rd-party libs (never `server/` or `cli/`). +- `server/**` imports `shared/**`. +- `cli/**` imports `shared/**`. +- `cmd/**` are thin `main` packages that wire the relevant component. + +--- + +## 5. Technology choices (libraries) + +| Concern | Choice | Why | +|---|---|---| +| SQLite driver | `modernc.org/sqlite` | Pure-Go, no CGO → easy static binaries & cross-compile | +| HTTP router | `go-chi/chi/v5` | Lightweight, middleware-friendly, stdlib-compatible | +| Migrations | `embed` + tiny runner in `shared/db` | No extra tooling; runs on startup | +| Password hashing | `golang.org/x/crypto/bcrypt` | Simple, well-understood | +| Token generation | `crypto/rand` 32B → base64url; store SHA-256 | Standard, revocable | +| GPG / OpenPGP | `github.com/ProtonMail/go-crypto/openpgp` | Maintained OpenPGP in pure Go; keygen, clearsign, detached sig | +| `.deb` ar archive | `pault.ag/go/debian/deb` (+ `blakesmith/ar` fallback) | Debian-aware deb reader | +| Control parsing | `pault.ag/go/debian/control` | RFC822 control field parsing | +| Compression (xz) | `github.com/ulikunitz/xz` | For `control.tar.xz` / `data.tar.xz` | +| Compression (zstd) | `github.com/klauspost/compress/zstd` | For `control.tar.zst` (newer debs) | +| CLI framework | `github.com/spf13/cobra` | Widely known, subcommands, flags | +| Config | `github.com/BurntSushi/toml` + env + flags | TOML file + env overrides + flag overrides | +| Logging | `log/slog` (stdlib) | Structured logging, no dep | +| Validation | `github.com/go-playground/validator/v10` | DTO validation | +| Testing | `testing` + `github.com/stretchr/testify` | Unit + integration | + +Go version: **1.22+** (for `log/slog`, enhanced `ServeMux` if needed). + +--- + +## 6. Data model (SQLite schema) + +All IDs are text UUIDv4. Timestamps are ISO-8601 UTC text. Booleans are INTEGER +0/1. SQLite in **WAL** mode, `busy_timeout=5000`, `foreign_keys=ON`. + +### `users` +| col | type | notes | +|---|---|---| +| id | TEXT PK | uuid | +| username | TEXT UNIQUE NOT NULL | case-insensitive; store lowercased + original | +| password_hash | TEXT NOT NULL | bcrypt | +| is_admin | INTEGER NOT NULL DEFAULT 0 | 1 for admins | +| created_at | TEXT NOT NULL | | +| updated_at | TEXT NOT NULL | | + +### `api_tokens` +| col | type | notes | +|---|---|---| +| id | TEXT PK | uuid | +| user_id | TEXT FK→users.id | | +| name | TEXT NOT NULL | user label e.g. "laptop" | +| prefix | TEXT NOT NULL | first 8 chars of token (for identification) | +| token_hash | TEXT UNIQUE NOT NULL | SHA-256 of full token | +| created_at | TEXT NOT NULL | | +| last_used_at | TEXT | nullable | +| revoked_at | TEXT | nullable; if set, invalid | + +### `repositories` +| col | type | notes | +|---|---|---| +| id | TEXT PK | | +| name | TEXT UNIQUE NOT NULL | URL-safe `[a-z0-9-]+`, lowercase | +| owner_user_id | TEXT FK→users.id | implicit full access | +| visibility | TEXT NOT NULL | `public` \| `private` | +| description | TEXT | nullable | +| created_at | TEXT NOT NULL | | +| updated_at | TEXT NOT NULL | | + +> v1 uses a single server-wide signing key, so no `signing_key_id` column. A +> `gpg_keys` table still holds that one key (see below). + +### `repository_members` +| col | type | notes | +|---|---|---| +| repository_id | TEXT FK→repositories.id | | +| user_id | TEXT FK→users.id | | +| access | TEXT NOT NULL | `read` \| `write` \| `read-write` \| `admin` | +| created_at | TEXT NOT NULL | | +| PK | (repository_id, user_id) | | + +- `read`: can read/download (private repos) and list. +- `write`: can push packages (no read). +- `read-write`: both. +- `admin`: both + manage members. Owner is implicitly `admin`. + +### `distributions` (suites) +| col | type | notes | +|---|---|---| +| id | TEXT PK | | +| repository_id | TEXT FK→repositories.id | | +| name | TEXT NOT NULL | e.g. `stable`, `testing`, `jammy` | +| created_at | TEXT NOT NULL | | +| UNIQUE | (repository_id, name) | | + +### `components` +| col | type | notes | +|---|---|---| +| id | TEXT PK | | +| distribution_id | TEXT FK→distributions.id | | +| name | TEXT NOT NULL | e.g. `main`, `contrib` | +| created_at | TEXT NOT NULL | | +| UNIQUE | (distribution_id, name) | | + +### `architectures` +| col | type | notes | +|---|---|---| +| id | TEXT PK | | +| distribution_id | TEXT FK→distributions.id | | +| name | TEXT NOT NULL | e.g. `amd64`, `arm64` (not `all` — `all` is implicit) | +| created_at | TEXT NOT NULL | | +| UNIQUE | (distribution_id, name) | | + +> `all` is **not** stored as an architecture row. Architecture-independent +> packages (arch=`all`) are listed in **every** binary-`` Packages index. +> The Release `Architectures` field lists the configured architectures. + +### `packages` (one row per uploaded .deb version) +| col | type | notes | +|---|---|---| +| id | TEXT PK | | +| repository_id | TEXT FK | | +| distribution_id | TEXT FK | | +| component_id | TEXT FK | | +| name | TEXT NOT NULL | from control `Package` | +| version | TEXT NOT NULL | from control `Version` | +| architecture | TEXT NOT NULL | from control `Architecture` (incl. `all`) | +| source | TEXT | from control `Source` (source pkg name) | +| maintainer | TEXT | | +| priority | TEXT | | +| section | TEXT | | +| origin | TEXT | | +| homepage | TEXT | | +| description | TEXT | full (extended) | +| description_md5 | TEXT | md5 of short description | +| depends | TEXT | | +| pre_depends | TEXT | | +| recommends | TEXT | | +| suggests | TEXT | | +| conflicts | TEXT | | +| breaks | TEXT | | +| provides | TEXT | | +| replaces | TEXT | | +| enhances | TEXT | | +| installed_size | INTEGER | kB | +| essential | TEXT | nullable | +| built_using | TEXT | nullable | +| tag | TEXT | nullable | +| raw_control | TEXT NOT NULL | full control stanza (re-emitted in index) | +| filename | TEXT NOT NULL | real file: `store/packages/.deb` | +| pool_path | TEXT NOT NULL | virtual: `pool////.deb` | +| size | INTEGER NOT NULL | .deb file size in bytes | +| md5sum | TEXT NOT NULL | of .deb | +| sha1 | TEXT NOT NULL | of .deb | +| sha256 | TEXT NOT NULL | of .deb | +| uploaded_by_user_id | TEXT FK→users.id | | +| created_at | TEXT NOT NULL | | +| UNIQUE | (repository_id, distribution_id, component_id, name, version, architecture) | | + +### `blobs` (content-addressed .deb files; reference counting + dedup) +| col | type | notes | +|---|---|---| +| sha256 | TEXT PK | | +| filename | TEXT NOT NULL | `store/packages/.deb` | +| size | INTEGER NOT NULL | | +| ref_count | INTEGER NOT NULL DEFAULT 0 | | +| created_at | TEXT NOT NULL | | + +> On push: compute sha256 → find-or-create blob (ref_count++) → insert package. +> On package delete: ref_count--; when 0, delete the file and the blob row. + +### `gpg_keys` (server-managed signing keys) +| col | type | notes | +|---|---|---| +| id | TEXT PK | | +| fingerprint | TEXT UNIQUE NOT NULL | | +| user_id | TEXT NOT NULL | OpenPGP user-id string, e.g. `urapt-server ` | +| public_key_armored | TEXT NOT NULL | exported ASCII pubkey (served to clients) | +| private_key_armored | TEXT NOT NULL | armored private key (see security note) | +| is_default | INTEGER NOT NULL DEFAULT 0 | the one default server key | +| created_at | TEXT NOT NULL | | + +> **Security note:** the private key is stored in the SQLite DB. v1 stores it +> armored without passphrase (acceptable for a self-hosted single-binary where +> the operator controls the DB file). Future: encrypt at rest with a passphrase +> from config/env (AES-GCM), and/or support per-repo keys. Document the +> tradeoff in README. + +### `audit_log` (lightweight, best-effort) +| col | type | notes | +|---|---|---| +| id | TEXT PK | | +| user_id | TEXT | nullable (system events) | +| repository_id | TEXT | nullable | +| action | TEXT NOT NULL | e.g. `package.push`, `repo.create`, `member.add` | +| target | TEXT | human-readable subject | +| details | TEXT | JSON blob | +| created_at | TEXT NOT NULL | | + +### `schema_migrations` +| col | type | notes | +|---|---|---| +| version | INTEGER PK | migration number | +| applied_at | TEXT NOT NULL | | + +--- + +## 7. APT repository layout & serving + +The APT endpoint base path is **`/apt//`**. apt clients use: + +``` +deb https:///apt// [ ...] +``` + +### On-disk vs virtual +- **Virtual** paths (served, never on disk): everything under `dists/` and the + `pool/` tree. Generated from the DB on demand. +- **Real** files on disk: only `store/packages/.deb`. + +### Routes served by the APT endpoint +| Route | Behavior | +|---|---| +| `GET /apt/:repo/dists/:suite/InRelease` | clearsigned Release (preferred by apt) | +| `GET /apt/:repo/dists/:suite/Release` | unsigned Release | +| `GET /apt/:repo/dists/:suite/Release.gpg` | detached signature of Release | +| `GET /apt/:repo/dists/:suite/:component/binary-:arch/Packages` | package index (text) | +| `GET /apt/:repo/dists/:suite/:component/binary-:arch/Packages.gz` | gzip | +| `GET /apt/:repo/dists/:suite/:component/binary-:arch/Packages.xz` | xz (optional) | +| `GET /apt/:repo/pool/:component/:letter/:src/:filename` | the `.deb` file (streamed, Range support) | + +- `:arch` excludes `all`; `all`-arch packages are merged into each real arch's + index. +- Pool path resolution: `pool////` → DB lookup + by `pool_path` within `:repo` → serve `store/packages/.deb` via + `http.ServeContent` (supports Range, ETag, Last-Modified). +- `:letter`/`:src` follow Debian convention: if source name starts with `lib`, + prefix = `lib` + first char after `lib` (e.g. `liba` for `libapache2…`); + else prefix = first char of source/package name. This is cosmetic — only + internal consistency matters. + +### Auth on the APT endpoint +- **Public repo:** no auth for any GET. +- **Private repo:** every GET requires **HTTP Basic auth** with + `username = `, `password = `. Server resolves the token, + checks the user has at least `read`/`write`/`read-write`/`admin` access (or is + owner/admin). On missing/invalid creds, respond `401` with + `WWW-Authenticate: Basic realm="urapt "` so apt's `auth.conf` triggers. + + Client `auth.conf` example: + ``` + machine + login + password + ``` + +--- + +## 8. Index generation (`shared/apt`) + +All indices are built **in memory from the DB** and may be cached (see §11). +Never written to disk. + +### Packages index (per component + arch) +For `(repo, suite, component, arch)`: +1. Query all `packages` rows matching `distribution_id`, `component_id`, and + `(architecture = arch OR architecture = 'all')`. +2. For each row, emit a stanza starting with the control fields (from + `raw_control`, filtered/normalized) plus the file fields: + ``` + Package: + Version: + Architecture: + Filename: # e.g. pool/main/f/foo/foo_1.0_amd64.deb + Size: + MD5sum: + SHA1: + SHA256: + ...other control fields... + Description: + ``` +3. Entries separated by a blank line; file ends with a blank line. +4. Serve as `Packages`; also serve gzip (`Packages.gz`) and xz (`Packages.xz`). + +### Release file (per suite) +1. Determine components (list of component names for the suite) and + architectures (configured arch names). +2. For each `(component, arch)` generate the `Packages`, `Packages.gz`, + `Packages.xz` bytes (reuse from the per-arch generation). +3. Compute checksums + sizes of each, keyed by their **path relative to the + suite**, e.g. `main/binary-amd64/Packages`. +4. Emit: + ``` + Origin: urapt + Label: urapt + Suite: + Codename: + Date: + Architectures: amd64 arm64 + Components: main contrib + Description: + MD5Sum: + main/binary-amd64/Packages + ... + SHA1: + main/binary-amd64/Packages + ... + SHA256: + main/binary-amd64/Packages + ... + ``` + - No `Valid-Until` (avoid expiry on quiet self-hosted repos). + - `Date` is regenerated on cache invalidation. + +### InRelease +- `InRelease` = **clearsigned** `Release` (inline OpenPGP signature) using the + server's default GPG key. Preferred by modern apt. + +### Release.gpg +- **Detached** signature of `Release` using the same key. For older apt flows. + +### Caching +- A per-`(repo, suite)` cache holds: `{Packages map, Release bytes, InRelease + bytes, Release.gpg bytes, generation int}`. +- Generation is bumped on any mutation affecting that `(repo, suite)`: + package push/delete, component/arch add/remove, distribution rename/delete, + repo visibility change. +- Cache is in-memory only; rebuilt lazily on first request after a bump or after + server restart. Mutex per key. + +--- + +## 9. GPG signing (`shared/gpg`) + +- Uses `github.com/ProtonMail/go-crypto/openpgp`. +- **Key generation** (on first server startup if no default key exists): + - Key type: **RSA-4096** (broad apt/gpg compatibility; configurable later). + - User-id: configurable, default `urapt-server `. + - No passphrase (v1; stored armored in DB). + - Persist armored public + private key to `gpg_keys` with `is_default=1`. +- **Operations:** + - `Clearsign(data) → InRelease` + - `DetachedSign(data) → Release.gpg` + - `ExportPublic() → ascii-armored pubkey` (served at `/api/v1/server/pubkey` + and `/apt/:repo/...` is signed by it). +- The pubkey is also exposed via the CLI (`urapt repo pubkey`) and the + `apt-config` helper prints instructions to install it + (`gpg --dearmor | tee /usr/share/keyrings/urapt.gpg` + signed-by line). + +--- + +## 10. `.deb` parsing (`shared/deb`) + +On push, the server must extract control metadata **without executing anything +from the package**. + +### Steps +1. Open the `.deb` as an `ar` archive (`pault.ag/go/debian/deb` or raw ar). +2. Read the `debian-binary` member (validate version `2.0`). +3. Locate the `control.tar.*` member; decompress (gzip / xz / zstd by magic + bytes). +4. From the tar, read the `control` file (and optional `shlibs`, `symbols`, + `triggers` — ignored for v1). +5. Parse the control stanza (`pault.ag/go/debian/control`): RFC822-style fields. +6. Extract all fields needed for the `packages` row (see §6) and keep the full + `raw_control` for re-emission. +7. Compute file hashes (MD5, SHA1, SHA256) and size **of the whole `.deb`**. + +### Validation on push +- Must be a valid `ar` archive with `debian-binary` + `control.tar.*`. +- `Package`, `Version`, `Architecture` must be present and non-empty. +- `Architecture` must be one of the distribution's configured arches **or** + `all`. +- Version must be a valid Debian version string (basic sanity). +- No duplicate of `(repo, distro, component, name, version, arch)` — else 409. +- Reject if `.deb` size > configured max (default 1 GiB). + +### Filename / pool path +- Real filename: `store/packages/.deb`. +- Pool path: `pool////` where + `` is the client-supplied `.deb` basename (sanitized) and + `` is the `Source` package name (or `Package` name if absent), and + `` per the Debian convention above. + +--- + +## 11. Storage layout + +``` +store/ + database/ + sqlite.db SQLite (WAL: sqlite.db-wal, sqlite.db-shm) + packages/ + .deb content-addressed; one file per unique .deb content +``` + +- Only `store/packages/*.deb` are filesystem user data. +- The server creates `store/`, `store/database/`, `store/packages/` on startup + if missing. +- Paths are configurable (`store_dir`, `db_path`, `packages_dir`). +- Backups: snapshot `sqlite.db` (with WAL checkpoint) + `packages/` dir. + +### Caching & invalidation (server) +- **Index cache**: in-memory, per `(repo, suite)`, generation-tagged (§8). +- **Mutation hooks**: every package/component/arch/distro mutation calls + `cache.Invalidate(repo, suite)` which bumps generation; next read rebuilds. +- No on-disk cache → restart simply rebuilds on first request. + +--- + +## 12. Authentication & authorization (`server/auth`) + +### Identity resolution +- **REST API:** `Authorization: Bearer ` → SHA-256 → look up + `api_tokens` (non-revoked) → load user. Update `last_used_at` (throttled). + Missing/invalid → `401`. +- **APT endpoint (private repos):** `Authorization: Basic ...` → decode → + `password` is the token → same resolution. + +### Permission helpers +- `CanRead(user, repo)`: repo public → true; else owner / member with + `read`/`write`/`read-write`/`admin` / server admin. +- `CanWrite(user, repo)`: owner / member with `write`/`read-write`/`admin` / + server admin. +- `CanManage(user, repo)`: owner / member with `admin` / server admin. +- Server admin (`users.is_admin=1`) bypasses all checks. + +### Endpoint-level enforcement +- All `/api/v1/**` except `auth/register`, `auth/login`, `server/info`, + `server/pubkey` require a valid identity. +- Write operations require `CanWrite`; member management requires `CanManage`; + user-management requires server admin. +- APT read: `CanRead` (and for public repos, anonymous allowed). + +--- + +## 13. REST API specification + +Base: `/api/v1`. JSON in/out unless noted. All list endpoints support +`?page=&per_page=` (default 25, max 100) and return `{items, page, per_page, +total}`. + +### Server / setup +| Method | Path | Auth | Notes | +|---|---|---|---| +| GET | `/server/info` | none | `{version, needsSetup, defaultKeyFingerprint}` | +| GET | `/server/pubkey` | none | ASCII-armored default pubkey (`Content-Type: application/pgp-keys`) | + +### Auth +| Method | Path | Auth | Body / Notes | +|---|---|---|---| +| POST | `/auth/register` | none | `{username, password}` → `{user, token}`. First registration → admin. Disabled once `users` non-empty? No — open registration configurable; default open. | +| POST | `/auth/login` | none | `{username, password}` → `{user, token}` (creates a session token) | +| POST | `/auth/logout` | bearer | revokes the current token | +| GET | `/me` | bearer | current user | +| GET | `/me/tokens` | bearer | list the user's tokens | +| POST | `/me/tokens` | bearer | `{name}` → `{id, prefix, token}` (plain token returned once) | +| DELETE | `/me/tokens/:id` | bearer | revoke | + +> `register`/`login` both return a plain token once; the CLI stores it. + +### Users (admin) +| Method | Path | Auth | Notes | +|---|---|---|---| +| GET | `/users` | admin | list users | +| GET | `/users/:id` | admin | get user | +| PATCH | `/users/:id` | admin | `{is_admin?}` | +| DELETE | `/users/:id` | admin | delete user (cannot delete self) | + +### Repositories +| Method | Path | Auth | Notes | +|---|---|---|---| +| GET | `/repositories` | bearer | list repos visible to caller (owned + member + public) | +| POST | `/repositories` | bearer | `{name, visibility, description?}` → owner=caller | +| GET | `/repositories/:repo` | bearer* | repo detail + distros summary (*read check for private) | +| PATCH | `/repositories/:repo` | manage | `{visibility?, description?, name?}` | +| DELETE | `/repositories/:repo` | manage | delete repo + cascade | +| GET | `/repositories/:repo/members` | read | list members | +| POST | `/repositories/:repo/members` | manage | `{username, access}` | +| PATCH | `/repositories/:repo/members/:username` | manage | `{access}` | +| DELETE | `/repositories/:repo/members/:username` | manage | remove member | +| GET | `/repositories/:repo/pubkey` | read | ASCII-armored server pubkey (convenience) | + +### Distributions / components / architectures +| Method | Path | Auth | Notes | +|---|---|---|---| +| GET | `/repositories/:repo/distributions` | read | list | +| POST | `/repositories/:repo/distributions` | write | `{name}` | +| DELETE | `/repositories/:repo/distributions/:dist` | write | cascade delete its packages | +| GET | `/repositories/:repo/distributions/:dist/components` | read | | +| POST | `/repositories/:repo/distributions/:dist/components` | write | `{name}` | +| DELETE | `/repositories/:repo/distributions/:dist/components/:comp` | write | cascade | +| GET | `/repositories/:repo/distributions/:dist/architectures` | read | | +| POST | `/repositories/:repo/distributions/:dist/architectures` | write | `{name}` | +| DELETE | `/repositories/:repo/distributions/:dist/architectures/:arch` | write | (reject if packages reference it and arch!=all) | + +### Packages +| Method | Path | Auth | Notes | +|---|---|---|---| +| GET | `/repositories/:repo/distributions/:dist/packages` | read | filters: `?component=&arch=&name=&q=` | +| GET | `/repositories/:repo/packages/:id` | read | package metadata | +| POST | `/repositories/:repo/distributions/:dist/packages` | write | **multipart/form-data**: `file` = .deb, `component` = name. Parses, validates, stores. → `{package}` | +| GET | `/repositories/:repo/packages/:id/file` | read | stream the `.deb` (CLI `pull`) | +| DELETE | `/repositories/:repo/packages/:id` | write | delete version (decrements blob ref) | + +### Errors +Uniform `{error: {code, message, details?}}` with appropriate HTTP status +(`400` validation, `401` unauth, `403` forbidden, `404` not found, `409` +conflict/duplicate, `500` server). + +--- + +## 14. APT endpoint specification + +Routes (see §7). Behavior summary: +- All under `/apt/:repo/...`. +- Public repo: anonymous GETs. +- Private repo: HTTP Basic required (token as password); `401` + challenge + otherwise. +- Indices generated via `shared/apt` with the in-memory cache. +- Pool files served via `http.ServeContent` (Range, ETag by sha256). +- `404` for unknown repo/suite/component/arch. +- The APT endpoint is mounted on the **same HTTP server** as the REST API, just + a different path prefix and a different auth mode (Basic vs Bearer). + +--- + +## 15. CLI design (`cmd/urapt`) + +### Local config & token store +- File: `~/.config/urapt/config.toml` (perm `0600`). +- Contents: + ```toml + [default] + server = "https://apt.example.com" + user = "alice" + token = "urapt_..." # stored; future: OS keychain + ``` +- `--server`, `--user`, `--token` flags override; env `URAPT_SERVER`, + `URAPT_TOKEN` also supported. +- Future: integrate `go-keyring` for the token. + +### Commands (cobra) +``` +urapt version +urapt login [] [--username] # prompts password; stores token +urapt logout +urapt whoami +urapt register [] [--username] # create account (first → admin) + +urapt token create [--name] +urapt token list +urapt token revoke + +urapt repo create [--public|--private] [--description] +urapt repo list +urapt repo info +urapt repo set-visibility --public|--private +urapt repo delete +urapt repo pubkey [-o file] + +urapt repo members list +urapt repo members add --access=read|write|read-write|admin +urapt repo members update --access=... +urapt repo members remove + +urapt distro create +urapt distro list +urapt distro delete + +urapt component create +urapt component list +urapt component delete + +urapt arch add +urapt arch list +urapt arch remove + +urapt push [--arch=] +urapt pull [@][:] [-o file] # or by id via --id +urapt ls [--component] [--arch] [--name] [-q] +urapt show +urapt rm + +urapt apt-config [--component=main] [--signed-by=/usr/share/keyrings/urapt.gpg] + # prints: sources.list line, pubkey install steps, and (if private) auth.conf +``` + +### Output +- Human-readable tables by default; `--json` for scripting. +- `urapt apt-config` is the key UX helper: it fetches the pubkey, prints the + signed-by sources line, and (for private repos) the `auth.conf` snippet. + +### Push flow (detailed) +1. Resolve token from config. +2. Verify the `.deb` locally (`shared/deb`) to give early, clear errors. +3. `POST /repositories/:repo/distributions/:dist/packages` (multipart) with + `component` and the file. +4. Server parses/validates/stores, returns the package record. +5. CLI prints the new package's `name_version_arch` and pool path. + +--- + +## 16. Server configuration + +Sources, precedence **low → high**: defaults → TOML file (`--config`, default +`./urapt-server.toml`) → env (`URAPT_*`) → CLI flags. + +| Key | Env | Default | Notes | +|---|---|---|---| +| `bind` | `URAPT_BIND` | `0.0.0.0:8080` | listen address | +| `base_url` | `URAPT_BASE_URL` | `http://localhost:8080` | external URL for generated apt config / pubkey links | +| `store_dir` | `URAPT_STORE_DIR` | `./store` | | +| `db_path` | `URAPT_DB_PATH` | `/database/sqlite.db` | | +| `packages_dir` | `URAPT_PACKAGES_DIR` | `/packages` | | +| `log_level` | `URAPT_LOG_LEVEL` | `info` | debug/info/warn/error | +| `signing_key_type` | `URAPT_SIGNING_KEY_TYPE` | `rsa` | | +| `signing_key_bits` | `URAPT_SIGNING_KEY_BITS` | `4096` | | +| `signing_key_user_id` | `URAPT_SIGNING_KEY_USER_ID` | `urapt-server ` | | +| `max_package_size` | `URAPT_MAX_PACKAGE_SIZE` | `1073741824` (1 GiB) | | +| `open_registration` | `URAPT_OPEN_REGISTRATION` | `true` | allow new account registration | +| `tls_enabled` | `URAPT_TLS_ENABLED` | `false` | optional built-in TLS | +| `tls_cert` / `tls_key` | … | — | paths if TLS enabled | + +> For internet-facing deployments, recommend a TLS-terminating reverse proxy +> (Caddy/nginx) over built-in TLS. + +### Startup sequence (`server/app`) +1. Load config. +2. Ensure `store_dir`, `db_path` parent, `packages_dir` exist. +3. Open SQLite (WAL, foreign_keys, busy_timeout); run migrations. +4. Ensure a default GPG key exists (generate if missing). +5. Build index cache; wire router (REST + APT); start HTTP server. +6. Log `base_url`, `needsSetup` (no users yet), and pubkey fingerprint. + +--- + +## 17. Security considerations + +- Passwords: bcrypt (cost 12). +- Tokens: 32-byte `crypto/rand`, base64url, prefixed `urapt_`; store only + SHA-256; `prefix` column for display; revocable; `last_used_at` tracking. +- Private repo APT reads: HTTP Basic over **TLS only** (warn if plain HTTP + + private repo). +- GPG private key in DB: documented tradeoff; future encryption-at-rest. +- File uploads: stream to a temp file, hash, then move to + `store/packages/.deb` (no execution, no path traversal — pool paths + are generated server-side, not from client input). +- Input validation: repo/distro/component/arch names restricted to safe + charset (`[a-z0-9][a-z0-9-+.]*`, lowercase); usernames `[a-z0-9_-]{3,32}`. +- Rate limiting on `auth/login` and `auth/register` (future; note in README). +- No CORS by default (API-only; future web UI would add it). +- `ServeContent` for pool files prevents directory traversal (DB lookup by + exact `pool_path`). + +--- + +## 18. Testing strategy + +### Unit (`shared/**`) +- `shared/crypto`: hash/verify, token round-trip. +- `shared/deb`: parse a fixture `.deb` (gzip/xz/zstd control.tar), assert + fields, hashes, sizes. +- `shared/apt`: given in-memory package rows, assert `Packages`, `Release`, + `InRelease`, `Release.gpg` byte content and checksum correctness. +- `shared/gpg`: generate key, clearsign + verify, detached sign + verify + round-trip using the same library. + +### Integration (`server/**`, `cli/**`) +- `httptest.Server` + temp SQLite + temp `store/`. +- REST: register → login → create repo → add distro/component/arch → push + fixture `.deb` → list → pull → delete; permission matrix tests. +- APT: push a fixture, then `GET` `InRelease`/`Release`/`Packages`/pool and + assert content + signature verification with the server pubkey. +- Private repo: assert `401` without Basic, `200` with token Basic. +- CLI: thin tests using a fake `apiclient` (interface-based) for command + output; plus an end-to-end test spinning the server against a temp dir. + +### Full apt integration (CI, optional) +- A Debian-based Docker container test: install a built `urapt-server`, push a + real `.deb`, configure apt against it, run `apt-get update && apt-get install + `, assert the package installs. Runs in CI matrix (not required for + `go test`). + +### Lint / format +- `go vet ./...`, `gofmt -l`, `golangci-lint run` (Makefile targets). + +--- + +## 19. Deployment + +### Docker +- Multi-stage `Dockerfile`: `golang:1.22` build → `gcr.io/distroless/static` + (or `alpine`) runtime. Expose `8080`. Volume `/data` mapped to `store_dir`. +- `docker-compose.yml`: one service, persistent volume, env for `base_url`, + reverse-proxy example. + +### Releases +- `Makefile` builds `urapt-server` and `urapt` for + `linux/amd64,linux/arm64,darwin/amd64,darwin/arm64` (CGO disabled via + `modernc.org/sqlite`). +- GitHub Releases with archive + checksums (future CI). + +### First-run UX (documented in README) +1. Run `urapt-server` (or docker compose up). +2. `urapt register` → you become admin. +3. `urapt repo create myrepo --public`. +4. `urapt distro create myrepo stable`. +5. `urapt component create myrepo stable main`. +6. `urapt arch add myrepo stable amd64`. +7. `urapt push myrepo stable main ./foo_1.0_amd64.deb`. +8. `urapt apt-config myrepo stable` → paste into client `/etc/apt/sources.list.d/myrepo.list`, install pubkey. +9. `sudo apt-get update && sudo apt-get install foo`. + +--- + +## 20. Implementation phases & tasks + +Each phase ends with `go build ./...` + `go test ./...` green. + +### Phase 0 — Scaffold +- [ ] `go mod init urapt`; Go 1.22. +- [ ] Directory tree (§4); empty packages with doc comments. +- [ ] `Makefile` (`build`, `test`, `vet`, `fmt`, `lint`, `run-server`, `run-cli`). +- [ ] `.gitignore` (`store/`, `*.db*`, `urapt`, `urapt-server`). +- [ ] `shared/version`, `cmd/urapt-server/main.go` + `cmd/urapt/main.go` stubs. + +### Phase 1 — Shared core +- [ ] `shared/config`: struct + TOML + env + flag loading; defaults. +- [ ] `shared/db`: open sqlite (WAL/FK/busy), `embed` migrations, runner, + query helpers (`*sql.DB` wrapper). +- [ ] `migrations/0001_init.sql` (all tables in §6). +- [ ] `shared/models`: Go structs for every table. +- [ ] `shared/crypto`: bcrypt password, token gen + SHA-256, prefix. +- [ ] `shared/log`: slog wrapper. +- [ ] `shared/httputil`: JSON read/write, error rendering, bearer/basic parse. + +### Phase 2 — Shared domain utilities +- [ ] `shared/gpg`: keygen, export pubkey, clearsign, detached sign (round-trip + tests). +- [ ] `shared/deb`: ar open, control.tar extract (gz/xz/zstd), control parse, + full-field extraction + hashes/size (fixture tests). +- [ ] `shared/apt`: `Packages`, `Release`, `InRelease`, `Release.gpg` + generation + cache struct (unit tests with fake rows). + +### Phase 3 — Server: auth & users +- [ ] `server/app`: wiring, startup sequence, key init. +- [ ] `server/middleware`: recover, logging, bearer auth, basic auth. +- [ ] `server/auth`: identity resolution, `CanRead/Write/Manage`. +- [ ] `server/restapi`: `/server/info`, `/server/pubkey`, `/auth/register`, + `/auth/login`, `/auth/logout`, `/me`, `/me/tokens`, `/users*`. +- [ ] First-registration-is-admin logic. + +### Phase 4 — Server: repos & structure +- [ ] Repositories CRUD + members CRUD + visibility + pubkey. +- [ ] Distributions / components / architectures CRUD. +- [ ] Permission enforcement on all of the above. +- [ ] Cache invalidation hooks wired. + +### Phase 5 — Server: packages +- [ ] `POST .../packages`: multipart receive → `shared/deb` parse → validate → + store blob → insert row → invalidate cache. +- [ ] `GET .../packages` (list) + `GET .../packages/:id` + `GET .../file` + (stream) + `DELETE .../packages/:id` (blob refcount). +- [ ] Blob dedup + cleanup on refcount 0. + +### Phase 6 — Server: APT endpoint +- [ ] `server/aptrepo`: routes (§7), cache-backed handlers, pool serving via + `ServeContent`. +- [ ] Private-repo Basic auth + `401` challenge. +- [ ] Signature of Release/InRelease with default key. +- [ ] Integration tests: push → fetch indices → verify sigs → fetch pool. + +### Phase 7 — CLI: config & auth +- [ ] `cli/config`: load/save TOML, token store, flag/env overrides. +- [ ] `cli/commands`: `login`, `logout`, `whoami`, `register`, `version`, + `token *`. +- [ ] `shared/apiclient`: typed client for all endpoints used so far. + +### Phase 8 — CLI: repos & structure +- [ ] `repo *`, `repo members *`, `repo pubkey`, `distro *`, `component *`, + `arch *` commands. +- [ ] Extend `shared/apiclient`. + +### Phase 9 — CLI: packages & apt-config +- [ ] `push`, `pull`, `ls`, `show`, `rm`. +- [ ] `apt-config` helper (fetch pubkey, print sources + signed-by + auth.conf). +- [ ] End-to-end CLI↔server test (temp dir). + +### Phase 10 — Hardening, docs, packaging +- [ ] README quickstart + architecture summary. +- [ ] `Dockerfile` (multi-stage) + `docker-compose.yml`. +- [ ] Cross-compile Makefile targets. +- [ ] golangci-lint config; fix findings. +- [ ] Optional: Debian-container apt integration test. +- [ ] Security review pass (input validation, path traversal, authz matrix). + +--- + +## 21. Open / future + +- **Per-repo GPG keys** (each repo its own trust root) — schema already + extensible; add `repositories.signing_key_id`. +- **GPG private key encryption at rest** (passphrase from config/env). +- **OS keychain** for CLI token (`go-keyring`). +- **Source packages** (`deb-src`, `source/` indices). +- **AppStream / `dep11`** metadata; **`Contents`** indexes. +- **`Acquire-By-Hash`** for atomic updates. +- **Web UI** (small SPA) + CORS. +- **Rate limiting** on auth endpoints. +- **Built-in TLS** (Let's Encrypt autocert option). +- **Token scopes** (per-repo, read-only tokens). +- **Webhook on package push** (for downstream CI). +- **Open registration toggle / invite-only mode** (config exists; add invites). +- **Retention policies** (keep last N versions per package). + +--- + +## 22. Glossary + +- **Suite / Distribution** — e.g. `stable`, `testing`, `jammy`. The apt + `deb ... ` line. +- **Component** — e.g. `main`, `contrib`, `non-free`. A section within a suite. +- **Architecture** — e.g. `amd64`, `arm64`. `all` = architecture-independent. +- **Packages index** — `dists///binary-/Packages`: + lists every `.deb` in that slice with metadata + download path. +- **Release / InRelease / Release.gpg** — suite-level metadata + checksums of + the indices, signed so apt can trust them. +- **Pool** — flat directory tree where the actual `.deb` files live + (`pool////.deb`). +- **Blob** — a content-addressed `.deb` file in `store/packages/.deb`, + reference-counted so identical uploads deduplicate. diff --git a/README.md b/README.md new file mode 100644 index 0000000..5dfa7fe --- /dev/null +++ b/README.md @@ -0,0 +1,159 @@ +# urapt + +A self-hostable APT repository server with a companion CLI for pushing and +managing Debian/Ubuntu `.deb` packages under your logged-in user. + +urapt gives you your own `apt` server: run the server, log in with the CLI, +create repositories, and push `.deb` files. Clients configure `apt` against it +and install packages normally. Packages are stored as content-addressed files +on disk; everything else lives in a SQLite database. APT indices +(`Release`, `InRelease`, `Packages`) are generated on demand from the database +and signed with a server-managed OpenPGP key. + +## Components + +- **`urapt-server`** (`cmd/urapt-server`) — the REST API + APT endpoint server. +- **`urapt`** (`cmd/urapt`) — the CLI for pushing packages and managing repos. +- **`shared/`** — shared utilities (config, db, models, gpg, deb parsing, apt + index generation, the typed API client) used by both server and CLI. + +## Quick start + +### Run the server + +```bash +make build +./urapt-server --bind 0.0.0.0:8080 --base-url https://apt.example.com +``` + +Or with Docker: + +```bash +docker compose up -d # see docker-compose.yml +``` + +The server creates `store/database/sqlite.db` and `store/packages/` on first +run and generates an RSA-4096 signing key stored in the database. + +### Set up the CLI + +```bash +./urapt register https://apt.example.com # first account becomes admin +./urapt repo create myrepo --public +./urapt distro create myrepo stable +./urapt component create myrepo stable main +./urapt arch add myrepo stable amd64 +``` + +### Push a package + +```bash +./urapt push myrepo stable main ./hello_1.0.0_amd64.deb +``` + +### Configure apt clients + +```bash +./urapt apt-config myrepo stable +``` + +This prints the exact commands to install the signing key and add the +repository, for example: + +```bash +curl -fsSL https://apt.example.com/api/v1/server/pubkey \ + | sudo gpg --dearmor -o /usr/share/keyrings/urapt-myrepo.gpg +echo 'deb [arch=amd64 signed-by=/usr/share/keyrings/urapt-myrepo.gpg] https://apt.example.com/apt/myrepo/ stable main' \ + | sudo tee /etc/apt/sources.list.d/myrepo.list +sudo apt update +sudo apt install hello +``` + +For **private** repositories, `apt-config` also prints an +`/etc/apt/auth.conf.d/...` snippet using your API token as the password. + +## Architecture + +``` + +-------------------+ +-------------------+ + | urapt (CLI) | | urapt-server | + +---------+---------+ +---------+---------+ + | shared/ | shared/ + v v + +---------------------------------------------+ + | shared/ | + | config | db | models | gpg | deb | apt | | + | crypto | api(DTOs) | apiclient | httputil | + +---------------------------------------------+ + | | + +--> SQLite <---+ store/database/sqlite.db + store/packages/.deb (files only) +``` + +- The CLI never touches the DB or filesystem; it only talks to the REST API. +- Only uploaded `.deb` files are stored on disk (`store/packages/`), content- + addressed by SHA-256 and reference-counted for deduplication. +- APT indices are generated in memory from the DB and cached (invalidated on + any mutation); they are never written to disk. + +### Endpoints + +- **REST API** at `/api/v1/**` — auth, users, repositories, members, + distributions/components/architectures, packages. Bearer-token auth. +- **APT endpoint** at `/apt/:repo/**` — serves `dists/.../{Release,InRelease, + Release.gpg}`, `Packages[.gz|.xz]`, and `pool/.../*.deb`. Public repos allow + anonymous reads; private repos require HTTP Basic auth (password = API token). + +### Permissions + +Each repository has an owner with full access. The owner can grant `read`, +`write`, `read-write`, or `admin` to other users. Only users with access can +push. Server admins can manage everything. + +## Configuration + +Server config is loaded from defaults → TOML file (`--config`, default +`./urapt-server.toml`) → environment (`URAPT_*`) → flags. Key options: + +| Key | Default | Notes | +|---|---|---| +| `bind` | `0.0.0.0:8080` | listen address | +| `base_url` | `http://localhost:8080` | external URL for apt-config output | +| `store_dir` | `./store` | data directory | +| `signing_key_bits` | `4096` | RSA signing key size | +| `max_package_size` | `1073741824` | 1 GiB upload limit | +| `open_registration` | `true` | allow new account registration | + +## Building + +Requires Go 1.22+. CGO is not required (SQLite is the pure-Go `modernc` +driver), so binaries are static and cross-compilable. + +```bash +make build # builds urapt-server and urapt +make test # go test ./... +make vet # go vet ./... +``` + +## Security notes + +- Passwords are bcrypt-hashed; API tokens are random 32-byte values stored only + as SHA-256 hashes (revocable, with a display prefix). +- The OpenPGP **private signing key is stored unencrypted in the SQLite + database**. This is acceptable when you control the database file; for + stronger protection, restrict file permissions and back up the DB securely. + Per-repo keys and key encryption-at-rest are planned. +- For internet-facing deployments, run behind a TLS-terminating reverse proxy + (Caddy/nginx). Private-repo credentials must never travel over plain HTTP. + +## Status + +See `CHANGELOG.md` for release history and `PLAN.md` for the full design and +roadmap. Future work includes per-repo signing keys, key encryption-at-rest, +OS keychain token storage, source packages, AppStream metadata, and a web UI. + +## License + +urapt is free software released under the terms of the +[GNU General Public License v3.0 or later](LICENSE). See `LICENSE` for the +full text. diff --git a/cli/commands/aptconfig.go b/cli/commands/aptconfig.go new file mode 100644 index 0000000..aa338ef --- /dev/null +++ b/cli/commands/aptconfig.go @@ -0,0 +1,107 @@ +package commands + +import ( + "fmt" + "net/url" + "strings" + + "github.com/spf13/cobra" + + "urapt/shared/models" +) + +func (r *Root) aptConfigCmd() *cobra.Command { + var component, signedBy string + cmd := &cobra.Command{ + Use: "apt-config ", + Short: "Print apt client configuration (sources.list, key, and auth) for a repository", + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + repoName, dist := args[0], args[1] + c, err := r.client() + if err != nil { + return err + } + repo, err := c.GetRepository(repoName) + if err != nil { + return err + } + comps, err := c.ListComponents(repoName, dist) + if err != nil { + return err + } + arches, err := c.ListArchitectures(repoName, dist) + if err != nil { + return err + } + + server, err := r.server() + if err != nil { + return err + } + if signedBy == "" { + signedBy = "/usr/share/keyrings/urapt-" + repoName + ".gpg" + } + + compList := component + if compList == "" { + var names []string + for _, comp := range comps { + names = append(names, comp.Name) + } + compList = strings.Join(names, " ") + } + if compList == "" { + compList = "main" + } + + var archStr string + if len(arches) > 0 { + var names []string + for _, a := range arches { + names = append(names, a.Name) + } + archStr = strings.Join(names, ",") + } + + fmt.Println("# 1. Install the repository signing key:") + fmt.Printf("curl -fsSL %s/api/v1/server/pubkey | sudo gpg --dearmor -o %s\n\n", server, signedBy) + + fmt.Println("# 2. Add the repository to apt:") + line := fmt.Sprintf("deb [signed-by=%s]", signedBy) + if archStr != "" { + line = fmt.Sprintf("deb [arch=%s signed-by=%s]", archStr, signedBy) + } + line += fmt.Sprintf(" %s/apt/%s/ %s %s", server, repoName, dist, compList) + fmt.Printf("echo '%s' | sudo tee /etc/apt/sources.list.d/%s.list\n\n", line, repoName) + + fmt.Println("# 3. Update apt:") + fmt.Println("sudo apt update") + fmt.Println() + + if repo.Visibility == models.VisibilityPrivate { + host := hostOf(server) + fmt.Println("# 4. This repository is private. Configure apt credentials:") + fmt.Printf("sudo tee /etc/apt/auth.conf.d/%s.conf <", + Short: "Revoke an API token by id", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + client, err := r.client() + if err != nil { + return err + } + if err := client.RevokeToken(args[0]); err != nil { + return err + } + fmt.Println("Token revoked") + return nil + }, + } + return c +} diff --git a/cli/commands/helpers.go b/cli/commands/helpers.go new file mode 100644 index 0000000..1c08b44 --- /dev/null +++ b/cli/commands/helpers.go @@ -0,0 +1,19 @@ +package commands + +import ( + "os" + + "urapt/cli/interact" +) + +// resolvePassword returns the password from a flag, the URAPT_PASSWORD env +// variable, or an interactive prompt (in that order). +func (r *Root) resolvePassword(flag string) (string, error) { + if flag != "" { + return flag, nil + } + if v := os.Getenv("URAPT_PASSWORD"); v != "" { + return v, nil + } + return interact.ReadPassword("Password: ") +} diff --git a/cli/commands/packages.go b/cli/commands/packages.go new file mode 100644 index 0000000..8b25cf9 --- /dev/null +++ b/cli/commands/packages.go @@ -0,0 +1,257 @@ +package commands + +import ( + "fmt" + "strings" + + "github.com/spf13/cobra" + + "urapt/cli/output" + "urapt/shared/apiclient" + "urapt/shared/deb" + "urapt/shared/models" +) + +func (r *Root) pushCmd() *cobra.Command { + var archOverride string + cmd := &cobra.Command{ + Use: "push ", + Short: "Upload a .deb package to a repository", + Args: cobra.ExactArgs(4), + RunE: func(cmd *cobra.Command, args []string) error { + repo, dist, component, file := args[0], args[1], args[2], args[3] + + // Local pre-validation for early, clear errors. + inspected, err := deb.Inspect(file) + if err != nil { + return fmt.Errorf("invalid .deb: %w", err) + } + ctrl := inspected.Control + pkgName, ver, arch := ctrl.Get("Package"), ctrl.Get("Version"), ctrl.Get("Architecture") + if archOverride != "" { + arch = archOverride + } + if r.flagJSON { + // no-op: keep flag accepted + } else { + fmt.Printf("Pushing %s_%s_%s (%d bytes)\n", pkgName, ver, arch, inspected.Size) + } + + c, err := r.client() + if err != nil { + return err + } + pkg, err := c.PushPackage(repo, dist, component, file) + if err != nil { + return err + } + if r.flagJSON { + output.JSON(pkg) + } else { + fmt.Printf("Pushed %s_%s_%s to %s/%s/%s\n", pkg.Name, pkg.Version, pkg.Architecture, repo, dist, component) + fmt.Printf(" id: %s\n", pkg.ID) + fmt.Printf(" pool: %s\n", pkg.PoolPath) + fmt.Printf(" sha256: %s\n", pkg.SHA256) + } + return nil + }, + } + cmd.Flags().StringVar(&archOverride, "arch", "", "override architecture (rarely needed)") + return cmd +} + +func (r *Root) lsCmd() *cobra.Command { + var component, arch, name, query string + cmd := &cobra.Command{ + Use: "ls ", + Short: "List packages in a repository/distribution", + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + c, err := r.client() + if err != nil { + return err + } + resp, err := c.ListPackages(args[0], args[1], map[string]string{ + "component": component, "arch": arch, "name": name, "q": query, + }) + if err != nil { + return err + } + if r.flagJSON { + output.JSON(resp) + return nil + } + if len(resp.Items) == 0 { + fmt.Println("No packages.") + return nil + } + fmt.Printf("%-38s %-20s %-12s %-10s %s\n", "ID", "NAME", "VERSION", "ARCH", "SIZE") + for _, p := range resp.Items { + fmt.Printf("%-38s %-20s %-12s %-10s %d\n", shortID(p.ID), p.Name, p.Version, p.Architecture, p.Size) + } + fmt.Printf("\n%d package(s)\n", resp.Total) + return nil + }, + } + cmd.Flags().StringVar(&component, "component", "", "filter by component") + cmd.Flags().StringVar(&arch, "arch", "", "filter by architecture") + cmd.Flags().StringVar(&name, "name", "", "filter by exact name") + cmd.Flags().StringVarP(&query, "query", "q", "", "substring search on name/description") + return cmd +} + +func (r *Root) showCmd() *cobra.Command { + var dist string + cmd := &cobra.Command{ + Use: "show ", + Short: "Show package metadata", + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + c, err := r.client() + if err != nil { + return err + } + id, err := r.resolvePackageID(c, args[0], dist, args[1]) + if err != nil { + return err + } + p, err := c.GetPackage(args[0], id) + if err != nil { + return err + } + if r.flagJSON { + output.JSON(p) + return nil + } + printPackage(p) + return nil + }, + } + cmd.Flags().StringVar(&dist, "dist", "", "distribution (required for name-based specs)") + return cmd +} + +func (r *Root) pullCmd() *cobra.Command { + var dist, outFile string + cmd := &cobra.Command{ + Use: "pull ", + Short: "Download a package's .deb file", + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + c, err := r.client() + if err != nil { + return err + } + id, err := r.resolvePackageID(c, args[0], dist, args[1]) + if err != nil { + return err + } + pkg, err := c.GetPackage(args[0], id) + if err != nil { + return err + } + out := outFile + if out == "" { + out = baseName(pkg.PoolPath) + } + if err := c.DownloadPackage(args[0], id, out); err != nil { + return err + } + if outFile != "-" && !r.flagJSON { + fmt.Printf("Downloaded %s\n", out) + } + return nil + }, + } + cmd.Flags().StringVar(&dist, "dist", "", "distribution (required for name-based specs)") + cmd.Flags().StringVarP(&outFile, "out", "o", "", "output file (default: original filename; - for stdout)") + return cmd +} + +func (r *Root) rmCmd() *cobra.Command { + var dist string + cmd := &cobra.Command{ + Use: "rm ", + Short: "Delete a package", + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + c, err := r.client() + if err != nil { + return err + } + id, err := r.resolvePackageID(c, args[0], dist, args[1]) + if err != nil { + return err + } + if err := c.DeletePackage(args[0], id); err != nil { + return err + } + fmt.Printf("Deleted package %s\n", id) + return nil + }, + } + cmd.Flags().StringVar(&dist, "dist", "", "distribution (required for name-based specs)") + return cmd +} + +// resolvePackageID resolves a spec ("id" or "name[@version][:arch]") to a +// package id. UUID specs are returned as-is. Name specs require --dist. +func (r *Root) resolvePackageID(c *apiclient.Client, repo, dist, spec string) (string, error) { + id, name, version, arch := apiclient.ParsePackageSpec(spec) + if id != "" { + return id, nil + } + if dist == "" { + return "", fmt.Errorf("name-based spec %q requires --dist", spec) + } + filters := map[string]string{"name": name} + resp, err := c.ListPackages(repo, dist, filters) + if err != nil { + return "", err + } + for _, p := range resp.Items { + if version != "" && p.Version != version { + continue + } + if arch != "" && p.Architecture != arch { + continue + } + return p.ID, nil + } + return "", fmt.Errorf("no package matching %s in %s/%s", spec, repo, dist) +} + +// printPackage prints a package's metadata in a readable key: value form. +func printPackage(p *models.Package) { + fmt.Printf("id: %s\n", p.ID) + fmt.Printf("name: %s\n", p.Name) + fmt.Printf("version: %s\n", p.Version) + fmt.Printf("architecture: %s\n", p.Architecture) + fmt.Printf("source: %s\n", p.Source) + fmt.Printf("maintainer: %s\n", p.Maintainer) + fmt.Printf("section: %s\n", p.Section) + fmt.Printf("priority: %s\n", p.Priority) + fmt.Printf("homepage: %s\n", p.Homepage) + fmt.Printf("depends: %s\n", p.Depends) + fmt.Printf("description: %s\n", p.Description) + fmt.Printf("pool_path: %s\n", p.PoolPath) + fmt.Printf("size: %d\n", p.Size) + fmt.Printf("sha256: %s\n", p.SHA256) + fmt.Printf("created_at: %s\n", p.CreatedAt) +} + +// shortID returns the first 8 chars of a UUID for compact display. +func shortID(id string) string { + if len(id) >= 8 { + return id[:8] + } + return id +} + +// baseName returns the last path segment. +func baseName(p string) string { + if i := strings.LastIndexByte(p, '/'); i >= 0 { + return p[i+1:] + } + return p +} diff --git a/cli/commands/repos.go b/cli/commands/repos.go new file mode 100644 index 0000000..34c91d1 --- /dev/null +++ b/cli/commands/repos.go @@ -0,0 +1,320 @@ +package commands + +import ( + "fmt" + "os" + + "github.com/spf13/cobra" + + "urapt/cli/output" +) + +func (r *Root) repoCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "repo", + Short: "Manage repositories", + } + cmd.AddCommand( + r.repoCreateCmd(), + r.repoListCmd(), + r.repoInfoCmd(), + r.repoSetVisibilityCmd(), + r.repoDeleteCmd(), + r.repoPubkeyCmd(), + r.repoMembersCmd(), + ) + return cmd +} + +func (r *Root) repoCreateCmd() *cobra.Command { + var visibility, description string + var public, private bool + cmd := &cobra.Command{ + Use: "create ", + Short: "Create a new repository", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + vis := visibility + if public { + vis = "public" + } else if private { + vis = "private" + } + if vis == "" { + vis = "private" + } + c, err := r.client() + if err != nil { + return err + } + repo, err := c.CreateRepository(args[0], vis, description) + if err != nil { + return err + } + if r.flagJSON { + output.JSON(repo) + } else { + fmt.Printf("Created repository %s (%s)\n", repo.Name, repo.Visibility) + } + return nil + }, + } + cmd.Flags().StringVar(&visibility, "visibility", "", "public or private") + cmd.Flags().BoolVar(&public, "public", false, "shorthand for --visibility=public") + cmd.Flags().BoolVar(&private, "private", false, "shorthand for --visibility=private") + cmd.Flags().StringVarP(&description, "description", "d", "", "repository description") + return cmd +} + +func (r *Root) repoListCmd() *cobra.Command { + return &cobra.Command{ + Use: "list", + Short: "List repositories visible to you", + RunE: func(cmd *cobra.Command, args []string) error { + c, err := r.client() + if err != nil { + return err + } + repos, err := c.ListRepositories() + if err != nil { + return err + } + if r.flagJSON { + output.JSON(repos) + return nil + } + if len(repos) == 0 { + fmt.Println("No repositories.") + return nil + } + fmt.Printf("%-20s %-10s %s\n", "NAME", "VISIBILITY", "DESCRIPTION") + for _, repo := range repos { + fmt.Printf("%-20s %-10s %s\n", repo.Name, repo.Visibility, repo.Description) + } + return nil + }, + } +} + +func (r *Root) repoInfoCmd() *cobra.Command { + return &cobra.Command{ + Use: "info ", + Short: "Show details of a repository", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + c, err := r.client() + if err != nil { + return err + } + repo, err := c.GetRepository(args[0]) + if err != nil { + return err + } + if r.flagJSON { + output.JSON(repo) + return nil + } + fmt.Printf("name: %s\n", repo.Name) + fmt.Printf("visibility: %s\n", repo.Visibility) + fmt.Printf("description: %s\n", repo.Description) + fmt.Printf("created: %s\n", repo.CreatedAt) + return nil + }, + } +} + +func (r *Root) repoSetVisibilityCmd() *cobra.Command { + var public, private bool + cmd := &cobra.Command{ + Use: "set-visibility ", + Short: "Change a repository's visibility", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + vis := "" + if public { + vis = "public" + } else if private { + vis = "private" + } + if vis == "" { + return fmt.Errorf("pass --public or --private") + } + c, err := r.client() + if err != nil { + return err + } + repo, err := c.UpdateRepository(args[0], nil, &vis, nil) + if err != nil { + return err + } + if r.flagJSON { + output.JSON(repo) + } else { + fmt.Printf("Updated %s: visibility=%s\n", repo.Name, repo.Visibility) + } + return nil + }, + } + cmd.Flags().BoolVar(&public, "public", false, "make public") + cmd.Flags().BoolVar(&private, "private", false, "make private") + return cmd +} + +func (r *Root) repoDeleteCmd() *cobra.Command { + return &cobra.Command{ + Use: "delete ", + Short: "Delete a repository and all its packages", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + c, err := r.client() + if err != nil { + return err + } + if err := c.DeleteRepository(args[0]); err != nil { + return err + } + fmt.Printf("Deleted repository %s\n", args[0]) + return nil + }, + } +} + +func (r *Root) repoPubkeyCmd() *cobra.Command { + var outFile string + cmd := &cobra.Command{ + Use: "pubkey ", + Short: "Print the server's armored public key for a repository", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + c, err := r.client() + if err != nil { + return err + } + pub, err := c.RepositoryPubkey(args[0]) + if err != nil { + return err + } + if outFile != "" { + return os.WriteFile(outFile, []byte(pub), 0o644) + } + fmt.Print(pub) + return nil + }, + } + cmd.Flags().StringVarP(&outFile, "out", "o", "", "write to file instead of stdout") + return cmd +} + +func (r *Root) repoMembersCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "members", + Short: "Manage repository members", + } + cmd.AddCommand( + r.repoMembersListCmd(), + r.repoMembersAddCmd(), + r.repoMembersUpdateCmd(), + r.repoMembersRemoveCmd(), + ) + return cmd +} + +func (r *Root) repoMembersListCmd() *cobra.Command { + return &cobra.Command{ + Use: "list ", + Short: "List members of a repository", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + c, err := r.client() + if err != nil { + return err + } + members, err := c.ListMembers(args[0]) + if err != nil { + return err + } + if r.flagJSON { + output.JSON(members) + return nil + } + fmt.Printf("%-20s %s\n", "USER", "ACCESS") + for _, m := range members { + fmt.Printf("%-20s %s\n", m.User.Username, m.Access) + } + return nil + }, + } +} + +func (r *Root) repoMembersAddCmd() *cobra.Command { + var access string + cmd := &cobra.Command{ + Use: "add ", + Short: "Grant a user access to a repository", + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + c, err := r.client() + if err != nil { + return err + } + m, err := c.AddMember(args[0], args[1], access) + if err != nil { + return err + } + if r.flagJSON { + output.JSON(m) + } else { + fmt.Printf("Granted %s access=%s on %s\n", m.User.Username, m.Access, args[0]) + } + return nil + }, + } + cmd.Flags().StringVarP(&access, "access", "a", "read", "read, write, read-write, or admin") + return cmd +} + +func (r *Root) repoMembersUpdateCmd() *cobra.Command { + var access string + cmd := &cobra.Command{ + Use: "update ", + Short: "Change a member's access level", + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + c, err := r.client() + if err != nil { + return err + } + m, err := c.UpdateMember(args[0], args[1], access) + if err != nil { + return err + } + if r.flagJSON { + output.JSON(m) + } else { + fmt.Printf("Updated %s access=%s on %s\n", m.User.Username, m.Access, args[0]) + } + return nil + }, + } + cmd.Flags().StringVarP(&access, "access", "a", "", "read, write, read-write, or admin") + return cmd +} + +func (r *Root) repoMembersRemoveCmd() *cobra.Command { + return &cobra.Command{ + Use: "remove ", + Short: "Revoke a user's access to a repository", + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + c, err := r.client() + if err != nil { + return err + } + if err := c.RemoveMember(args[0], args[1]); err != nil { + return err + } + fmt.Printf("Removed %s from %s\n", args[1], args[0]) + return nil + }, + } +} diff --git a/cli/commands/root.go b/cli/commands/root.go new file mode 100644 index 0000000..b17969c --- /dev/null +++ b/cli/commands/root.go @@ -0,0 +1,168 @@ +// Package commands implements the urapt CLI command tree using cobra. +package commands + +import ( + "fmt" + "os" + + "github.com/spf13/cobra" + + "urapt/cli/config" + "urapt/shared/apiclient" +) + +// Root holds the urapt CLI runtime state: the version, loaded config, and +// flag overrides. It builds and executes the cobra command tree. +type Root struct { + version string + + cfg *config.File + + flagServer string + flagUser string + flagToken string + flagJSON bool + + rootCmd *cobra.Command +} + +// New constructs the CLI root. +func New(version string) *Root { + r := &Root{version: version} + r.build() + return r +} + +// Execute runs the command tree with the given args. +func (r *Root) Execute(args []string) error { + r.rootCmd.SetArgs(args) + err := r.rootCmd.Execute() + if err != nil { + fmt.Fprintln(os.Stderr, "error:", err) + } + return err +} + +// build constructs the cobra root and attaches subcommands. +func (r *Root) build() { + r.rootCmd = &cobra.Command{ + Use: "urapt", + Short: "urapt is a CLI for managing packages on a self-hosted urapt APT server", + Long: "urapt pushes and manages Debian packages on a self-hosted urapt\n" + + "server. Log in once, then push .deb files to your repositories.", + SilenceUsage: true, + SilenceErrors: true, + } + r.rootCmd.PersistentFlags().StringVar(&r.flagServer, "server", "", "urapt server URL (overrides config)") + r.rootCmd.PersistentFlags().StringVar(&r.flagUser, "user", "", "username (overrides config)") + r.rootCmd.PersistentFlags().StringVar(&r.flagToken, "token", "", "API token (overrides config)") + r.rootCmd.PersistentFlags().BoolVar(&r.flagJSON, "json", false, "output JSON") + + r.rootCmd.AddCommand( + r.versionCmd(), + r.loginCmd(), + r.logoutCmd(), + r.whoamiCmd(), + r.registerCmd(), + r.tokenCmd(), + r.repoCmd(), + r.distroCmd(), + r.componentCmd(), + r.archCmd(), + r.pushCmd(), + r.pullCmd(), + r.lsCmd(), + r.showCmd(), + r.rmCmd(), + r.aptConfigCmd(), + ) +} + +// loadConfig lazily loads the CLI config file (once). +func (r *Root) loadConfig() error { + if r.cfg != nil { + return nil + } + cfg, err := config.Load() + if err != nil { + return err + } + r.cfg = cfg + return nil +} + +// server resolves the effective server URL (flag > env > config). +func (r *Root) server() (string, error) { + if err := r.loadConfig(); err != nil { + return "", err + } + if r.flagServer != "" { + return r.flagServer, nil + } + if v := os.Getenv("URAPT_SERVER"); v != "" { + return v, nil + } + if r.cfg.Default.Server != "" { + return r.cfg.Default.Server, nil + } + return "", fmt.Errorf("no server configured: run `urapt login ` or pass --server") +} + +// token resolves the effective API token (flag > env > config). +func (r *Root) token() (string, error) { + if r.flagToken != "" { + return r.flagToken, nil + } + if v := os.Getenv("URAPT_TOKEN"); v != "" { + return v, nil + } + if err := r.loadConfig(); err != nil { + return "", err + } + if r.cfg.Default.Token != "" { + return r.cfg.Default.Token, nil + } + return "", fmt.Errorf("not logged in: run `urapt login`") +} + +// client builds an authenticated client using the resolved server + token. +func (r *Root) client() (*apiclient.Client, error) { + server, err := r.server() + if err != nil { + return nil, err + } + tok, _ := r.token() + return apiclient.New(server, tok), nil +} + +// unauthClient builds a client with only the server resolved (for login/register). +func (r *Root) unauthClient(server string) (*apiclient.Client, error) { + if server == "" { + var err error + server, err = r.server() + if err != nil { + return nil, err + } + } + return apiclient.New(server, ""), nil +} + +// saveProfile persists the given server/user/token as the default profile. +func (r *Root) saveProfile(server, user, token string) error { + if err := r.loadConfig(); err != nil { + return err + } + r.cfg.Default.Server = server + r.cfg.Default.User = user + r.cfg.Default.Token = token + return config.Save(r.cfg) +} + +// clearProfile removes the stored token (used by logout). +func (r *Root) clearProfile() error { + if err := r.loadConfig(); err != nil { + return err + } + r.cfg.Default.Token = "" + return config.Save(r.cfg) +} diff --git a/cli/commands/structure.go b/cli/commands/structure.go new file mode 100644 index 0000000..df10e44 --- /dev/null +++ b/cli/commands/structure.go @@ -0,0 +1,243 @@ +package commands + +import ( + "fmt" + + "github.com/spf13/cobra" + + "urapt/cli/output" +) + +func (r *Root) distroCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "distro", + Short: "Manage distributions (suites) within a repository", + } + cmd.AddCommand(r.distroCreateCmd(), r.distroListCmd(), r.distroDeleteCmd()) + return cmd +} + +func (r *Root) distroCreateCmd() *cobra.Command { + return &cobra.Command{ + Use: "create ", + Short: "Add a distribution to a repository", + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + c, err := r.client() + if err != nil { + return err + } + d, err := c.CreateDistribution(args[0], args[1]) + if err != nil { + return err + } + if r.flagJSON { + output.JSON(d) + } else { + fmt.Printf("Created distribution %s in %s\n", d.Name, args[0]) + } + return nil + }, + } +} + +func (r *Root) distroListCmd() *cobra.Command { + return &cobra.Command{ + Use: "list ", + Short: "List distributions in a repository", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + c, err := r.client() + if err != nil { + return err + } + dists, err := c.ListDistributions(args[0]) + if err != nil { + return err + } + if r.flagJSON { + output.JSON(dists) + return nil + } + for _, d := range dists { + fmt.Println(d.Name) + } + return nil + }, + } +} + +func (r *Root) distroDeleteCmd() *cobra.Command { + return &cobra.Command{ + Use: "delete ", + Short: "Delete a distribution and its packages", + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + c, err := r.client() + if err != nil { + return err + } + if err := c.DeleteDistribution(args[0], args[1]); err != nil { + return err + } + fmt.Printf("Deleted distribution %s in %s\n", args[1], args[0]) + return nil + }, + } +} + +func (r *Root) componentCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "component", + Short: "Manage components within a distribution", + } + cmd.AddCommand(r.componentCreateCmd(), r.componentListCmd(), r.componentDeleteCmd()) + return cmd +} + +func (r *Root) componentCreateCmd() *cobra.Command { + return &cobra.Command{ + Use: "create ", + Short: "Add a component to a distribution", + Args: cobra.ExactArgs(3), + RunE: func(cmd *cobra.Command, args []string) error { + c, err := r.client() + if err != nil { + return err + } + comp, err := c.CreateComponent(args[0], args[1], args[2]) + if err != nil { + return err + } + if r.flagJSON { + output.JSON(comp) + } else { + fmt.Printf("Created component %s in %s/%s\n", comp.Name, args[0], args[1]) + } + return nil + }, + } +} + +func (r *Root) componentListCmd() *cobra.Command { + return &cobra.Command{ + Use: "list ", + Short: "List components in a distribution", + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + c, err := r.client() + if err != nil { + return err + } + comps, err := c.ListComponents(args[0], args[1]) + if err != nil { + return err + } + if r.flagJSON { + output.JSON(comps) + return nil + } + for _, comp := range comps { + fmt.Println(comp.Name) + } + return nil + }, + } +} + +func (r *Root) componentDeleteCmd() *cobra.Command { + return &cobra.Command{ + Use: "delete ", + Short: "Delete a component", + Args: cobra.ExactArgs(3), + RunE: func(cmd *cobra.Command, args []string) error { + c, err := r.client() + if err != nil { + return err + } + if err := c.DeleteComponent(args[0], args[1], args[2]); err != nil { + return err + } + fmt.Printf("Deleted component %s in %s/%s\n", args[2], args[0], args[1]) + return nil + }, + } +} + +func (r *Root) archCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "arch", + Short: "Manage architectures within a distribution", + } + cmd.AddCommand(r.archAddCmd(), r.archListCmd(), r.archRemoveCmd()) + return cmd +} + +func (r *Root) archAddCmd() *cobra.Command { + return &cobra.Command{ + Use: "add ", + Short: "Add an architecture to a distribution", + Args: cobra.ExactArgs(3), + RunE: func(cmd *cobra.Command, args []string) error { + c, err := r.client() + if err != nil { + return err + } + a, err := c.CreateArchitecture(args[0], args[1], args[2]) + if err != nil { + return err + } + if r.flagJSON { + output.JSON(a) + } else { + fmt.Printf("Added architecture %s to %s/%s\n", a.Name, args[0], args[1]) + } + return nil + }, + } +} + +func (r *Root) archListCmd() *cobra.Command { + return &cobra.Command{ + Use: "list ", + Short: "List architectures in a distribution", + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + c, err := r.client() + if err != nil { + return err + } + arches, err := c.ListArchitectures(args[0], args[1]) + if err != nil { + return err + } + if r.flagJSON { + output.JSON(arches) + return nil + } + for _, a := range arches { + fmt.Println(a.Name) + } + return nil + }, + } +} + +func (r *Root) archRemoveCmd() *cobra.Command { + return &cobra.Command{ + Use: "remove ", + Short: "Remove an architecture from a distribution", + Args: cobra.ExactArgs(3), + RunE: func(cmd *cobra.Command, args []string) error { + c, err := r.client() + if err != nil { + return err + } + if err := c.DeleteArchitecture(args[0], args[1], args[2]); err != nil { + return err + } + fmt.Printf("Removed architecture %s from %s/%s\n", args[2], args[0], args[1]) + return nil + }, + } +} diff --git a/cli/commands/version.go b/cli/commands/version.go new file mode 100644 index 0000000..234fe2a --- /dev/null +++ b/cli/commands/version.go @@ -0,0 +1,17 @@ +package commands + +import ( + "fmt" + + "github.com/spf13/cobra" +) + +func (r *Root) versionCmd() *cobra.Command { + return &cobra.Command{ + Use: "version", + Short: "Print the urapt CLI version", + Run: func(cmd *cobra.Command, args []string) { + fmt.Println(r.version) + }, + } +} diff --git a/cli/config/config.go b/cli/config/config.go new file mode 100644 index 0000000..4c39e7a --- /dev/null +++ b/cli/config/config.go @@ -0,0 +1,75 @@ +// Package config manages the urapt CLI's local configuration: the server URL, +// username, and API token stored in ~/.config/urapt/config.toml (perm 0600). +package config + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/BurntSushi/toml" +) + +// File is the on-disk CLI config shape. +type File struct { + Default Profile `toml:"default"` +} + +// Profile is the active connection profile. +type Profile struct { + Server string `toml:"server"` + User string `toml:"user"` + Token string `toml:"token"` +} + +// configPath returns the path to the CLI config file. +func configPath() (string, error) { + dir, err := os.UserConfigDir() + if err != nil { + dir = os.Getenv("HOME") + dir = filepath.Join(dir, ".config") + } + return filepath.Join(dir, "urapt", "config.toml"), nil +} + +// Load reads the CLI config, returning an empty config if none exists. +func Load() (*File, error) { + path, err := configPath() + if err != nil { + return nil, err + } + data, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return &File{}, nil + } + return nil, fmt.Errorf("read config: %w", err) + } + var f File + if err := toml.Unmarshal(data, &f); err != nil { + return nil, fmt.Errorf("parse config: %w", err) + } + f.Default.Server = strings.TrimRight(f.Default.Server, "/") + return &f, nil +} + +// Save writes the CLI config with 0600 permissions. +func Save(f *File) error { + path, err := configPath() + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + return fmt.Errorf("create config dir: %w", err) + } + var buf strings.Builder + enc := toml.NewEncoder(&buf) + if err := enc.Encode(f); err != nil { + return fmt.Errorf("encode config: %w", err) + } + return os.WriteFile(path, []byte(buf.String()), 0o600) +} + +// Path returns the config file path (for display). +func Path() (string, error) { return configPath() } diff --git a/cli/interact/interact.go b/cli/interact/interact.go new file mode 100644 index 0000000..ad4ee8f --- /dev/null +++ b/cli/interact/interact.go @@ -0,0 +1,40 @@ +// Package interact provides simple interactive prompts (passwords, confirms). +package interact + +import ( + "fmt" + "os" + "strings" + + "golang.org/x/term" +) + +// ReadPassword prompts for a password with input hidden. +func ReadPassword(prompt string) (string, error) { + fmt.Fprint(os.Stderr, prompt) + b, err := term.ReadPassword(int(os.Stdin.Fd())) + fmt.Fprintln(os.Stderr) + if err != nil { + return "", err + } + return string(b), nil +} + +// ReadLine prompts and reads a single line of input. +func ReadLine(prompt string) (string, error) { + fmt.Fprint(os.Stderr, prompt) + var s string + if _, err := fmt.Fscanln(os.Stdin, &s); err != nil { + return "", err + } + return strings.TrimSpace(s), nil +} + +// Confirm prompts a yes/no question, returning the boolean answer. +func Confirm(prompt string) bool { + fmt.Fprintf(os.Stderr, "%s [y/N]: ", prompt) + var s string + fmt.Fscanln(os.Stdin, &s) + s = strings.ToLower(strings.TrimSpace(s)) + return s == "y" || s == "yes" +} diff --git a/cli/output/output.go b/cli/output/output.go new file mode 100644 index 0000000..808c9e4 --- /dev/null +++ b/cli/output/output.go @@ -0,0 +1,24 @@ +// Package output provides small formatting helpers for CLI commands. +package output + +import ( + "encoding/json" + "fmt" + "os" +) + +// JSON prints v as indented JSON. +func JSON(v any) { + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + _ = enc.Encode(v) +} + +// Printf is a thin wrapper around fmt.Printf. +func Printf(format string, args ...any) { fmt.Printf(format, args...) } + +// Println prints a line. +func Println(args ...any) { fmt.Println(args...) } + +// Errorf prints to stderr. +func Errorf(format string, args ...any) { fmt.Fprintf(os.Stderr, format, args...) } diff --git a/cmd/urapt-server/main.go b/cmd/urapt-server/main.go new file mode 100644 index 0000000..0b67cd3 --- /dev/null +++ b/cmd/urapt-server/main.go @@ -0,0 +1,22 @@ +// Package main is the urapt-server entrypoint. +package main + +import ( + "context" + "os" + "os/signal" + "syscall" + + "urapt/server/app" + "urapt/shared/version" +) + +func main() { + a := app.New(os.Args[1:], version.Version) + ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + defer stop() + + if err := a.Run(ctx); err != nil { + os.Exit(1) + } +} diff --git a/cmd/urapt/main.go b/cmd/urapt/main.go new file mode 100644 index 0000000..d0fef31 --- /dev/null +++ b/cmd/urapt/main.go @@ -0,0 +1,15 @@ +// Package main is the urapt CLI entrypoint. +package main + +import ( + "os" + + "urapt/cli/commands" + "urapt/shared/version" +) + +func main() { + if err := commands.New(version.Version).Execute(os.Args[1:]); err != nil { + os.Exit(1) + } +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..f42955e --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,18 @@ +services: + urapt-server: + build: . + image: urapt-server:latest + ports: + - "8080:8080" + volumes: + - urapt-data:/data + environment: + URAPT_BIND: "0.0.0.0:8080" + URAPT_BASE_URL: "http://localhost:8080" + URAPT_STORE_DIR: "/data/store" + # URAPT_OPEN_REGISTRATION: "true" + # URAPT_SIGNING_KEY_USER_ID: "urapt-server " + restart: unless-stopped + +volumes: + urapt-data: diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..f2834c3 --- /dev/null +++ b/go.mod @@ -0,0 +1,36 @@ +module urapt + +go 1.26.1 + +require ( + github.com/BurntSushi/toml v1.6.0 + github.com/ProtonMail/go-crypto v1.4.1 + golang.org/x/crypto v0.53.0 + modernc.org/sqlite v1.53.0 +) + +require ( + github.com/cloudflare/circl v1.6.2 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/gabriel-vasile/mimetype v1.4.13 // indirect + github.com/go-chi/chi/v5 v5.3.0 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/go-playground/validator/v10 v10.30.3 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/klauspost/compress v1.18.6 // indirect + github.com/leodido/go-urn v1.4.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/ncruces/go-strftime v1.0.0 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + github.com/spf13/cobra v1.10.2 // indirect + github.com/spf13/pflag v1.0.9 // indirect + github.com/ulikunitz/xz v0.5.15 // indirect + golang.org/x/sys v0.46.0 // indirect + golang.org/x/term v0.44.0 // indirect + golang.org/x/text v0.38.0 // indirect + modernc.org/libc v1.73.4 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.11.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..fd64354 --- /dev/null +++ b/go.sum @@ -0,0 +1,89 @@ +github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= +github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= +github.com/ProtonMail/go-crypto v1.4.1 h1:9RfcZHqEQUvP8RzecWEUafnZVtEvrBVL9BiF67IQOfM= +github.com/ProtonMail/go-crypto v1.4.1/go.mod h1:e1OaTyu5SYVrO9gKOEhTc+5UcXtTUa+P3uLudwcgPqo= +github.com/cloudflare/circl v1.6.2 h1:hL7VBpHHKzrV5WTfHCaBsgx/HGbBYlgrwvNXEVDYYsQ= +github.com/cloudflare/circl v1.6.2/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4= +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM= +github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= +github.com/go-chi/chi/v5 v5.3.0 h1:halUjDxhshgXHMrao5bB8eNBXo/rnzwr8m5m36glehM= +github.com/go-chi/chi/v5 v5.3.0/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.30.3 h1:4MU6YkEwx7GbcPJOZxrtbu+QfF3pJLJuaYTeAH0DYy8= +github.com/go-playground/validator/v10 v10.30.3/go.mod h1:4Axh7oCNGcoGkqLoE4YWt6n20mcEIsPRlB7vPk3lpyc= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao= +github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= +github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= +github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= +github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/ulikunitz/xz v0.5.15 h1:9DNdB5s+SgV3bQ2ApL10xRc35ck0DuIX/isZvIk+ubY= +github.com/ulikunitz/xz v0.5.15/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto= +golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio= +golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4= +golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= +golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc= +golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y= +golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE= +golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4= +golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8= +golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +modernc.org/cc/v4 v4.28.4 h1:Hd/4Es+MBj+/7hSdZaisNyu6bv3V0Dp2MdllyfqaH+c= +modernc.org/cc/v4 v4.28.4/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI= +modernc.org/ccgo/v4 v4.34.4 h1:OVnSOWQjVKOYkFxoHYB+qQmSHK5gqMqARM+K9DpR/Ws= +modernc.org/ccgo/v4 v4.34.4/go.mod h1:qdKqE8FNIYyysougB1RX9MxCzp5oJOcQXSobANJ4TuE= +modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM= +modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU= +modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= +modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= +modernc.org/gc/v3 v3.1.3 h1:6QAplYyVO+KdPW3pGnqmJDUxtkec8ooEWvks/hhU3lc= +modernc.org/gc/v3 v3.1.3/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= +modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= +modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= +modernc.org/libc v1.73.4 h1:+ra4Ui8ngyt8HDcO1FTDPWlkAh6yOdaO2yAoh8MddQA= +modernc.org/libc v1.73.4/go.mod h1:DXZ3eO8qMCNn2SnmTNCiC71nJ9Rcq3PsnpU6Vc4rWK8= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= +modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg= +modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= +modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= +modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= +modernc.org/sqlite v1.53.0 h1:20WG8N9q4ji/dEqGk4uiI0c6OPjSeLTNYGFCc3+7c1M= +modernc.org/sqlite v1.53.0/go.mod h1:xoEpOIpGrgT48H5iiyt/YXPCZPEzlfmfFwtk8Lklw8s= +modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= +modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= diff --git a/server/app/app.go b/server/app/app.go new file mode 100644 index 0000000..b451729 --- /dev/null +++ b/server/app/app.go @@ -0,0 +1,211 @@ +// Package app wires the urapt-server dependencies and runs the HTTP server. +package app + +import ( + "context" + "errors" + "flag" + "fmt" + "net/http" + "os" + "path/filepath" + "time" + + "github.com/go-chi/chi/v5" + + "urapt/server/aptrepo" + "urapt/server/auth" + "urapt/server/cache" + "urapt/server/restapi" + "urapt/server/store" + "urapt/shared/config" + "urapt/shared/db" + "urapt/shared/gpg" + "urapt/shared/log" +) + +// App holds the server configuration and runtime dependencies. +type App struct { + args []string + version string +} + +// New constructs an App from the given command-line args and version string. +func New(args []string, version string) *App { + return &App{args: args, version: version} +} + +// signerProvider implements restapi.SignerProvider backed by a *gpg.Key. +type signerProvider struct { + key *gpg.Key +} + +func (s *signerProvider) PublicKeyArmored() (string, error) { + if s.key == nil { + return "", fmt.Errorf("no key") + } + return s.key.ArmoredPublic() +} + +func (s *signerProvider) Fingerprint() string { + if s.key == nil { + return "" + } + return s.key.Fingerprint +} + +// Key returns the signing key for the APT endpoint (Phase 6). +func (s *signerProvider) Key() *gpg.Key { return s.key } + +// Run loads config, opens the database, ensures a signing key, and serves +// HTTP until the context is cancelled. +func (a *App) Run(ctx context.Context) error { + cfg, err := a.loadConfig() + if err != nil { + fmt.Fprintln(os.Stderr, "config error:", err) + return err + } + logger := log.New(cfg.LogLevel) + logger.Info("starting urapt-server", "version", a.version, "bind", cfg.Bind, "base_url", cfg.BaseURL) + + if err := cfg.Validate(); err != nil { + logger.Error("invalid config", "err", err) + return err + } + + if err := ensureDirs(cfg); err != nil { + logger.Error("setup store dirs", "err", err) + return err + } + + database, err := db.Open(cfg.DBPath) + if err != nil { + logger.Error("open database", "err", err) + return err + } + defer database.Close() + + st := store.New(database) + authSvc := auth.NewService(st) + + signer, err := ensureSigningKey(ctx, st, cfg) + if err != nil { + logger.Error("ensure signing key", "err", err) + return err + } + sp := &signerProvider{key: signer} + logger.Info("signing key ready", "fingerprint", sp.Fingerprint()) + + idxCache := cache.New() + + root := chi.NewRouter() + root.Mount("/api/v1", restapi.New(st, authSvc, sp, &cfg, idxCache)) + root.Mount("/apt", aptrepo.New(st, authSvc, sp.Key(), idxCache)) + + srv := &http.Server{ + Addr: cfg.Bind, + Handler: root, + } + + errCh := make(chan error, 1) + go func() { + logger.Info("listening", "addr", cfg.Bind) + if cfg.TLSEnabled { + errCh <- srv.ListenAndServeTLS(cfg.TLSCert, cfg.TLSKey) + } else { + errCh <- srv.ListenAndServe() + } + }() + + select { + case <-ctx.Done(): + logger.Info("shutting down") + shutCtx, cancel := context.WithTimeout(context.Background(), shutdownTimeout) + defer cancel() + return srv.Shutdown(shutCtx) + case err := <-errCh: + if err != nil && !errors.Is(err, http.ErrServerClosed) { + logger.Error("server error", "err", err) + return err + } + } + return nil +} + +// loadConfig parses flags and builds the effective Config. +func (a *App) loadConfig() (config.Config, error) { + fs := flag.NewFlagSet("urapt-server", flag.ContinueOnError) + fs.SetOutput(os.Stderr) + configPath := fs.String("config", config.Defaults.ConfigPath, "path to config file") + fs.String("bind", "", "bind address") + fs.String("base-url", "", "external base URL") + fs.String("store-dir", "", "store directory") + fs.String("db-path", "", "sqlite db path") + fs.String("packages-dir", "", "packages directory") + fs.String("log-level", "", "log level") + fs.String("signing-key-type", "", "signing key type") + fs.Int("signing-key-bits", 0, "signing key bits") + fs.String("signing-key-user-id", "", "signing key user id") + fs.Int64("max-package-size", 0, "max package size in bytes") + fs.Bool("open-registration", true, "allow new account registration") + fs.Bool("tls-enabled", false, "enable TLS") + fs.String("tls-cert", "", "TLS cert path") + fs.String("tls-key", "", "TLS key path") + if err := fs.Parse(a.args); err != nil { + return config.Config{}, err + } + + flags := map[string]string{} + fs.Visit(func(f *flag.Flag) { + flags[f.Name] = f.Value.String() + }) + return config.Load(*configPath, flags) +} + +// ensureDirs creates the store, database, and packages directories. +func ensureDirs(cfg config.Config) error { + for _, dir := range []string{cfg.StoreDir, filepath.Dir(cfg.DBPath), cfg.PackagesDir} { + if dir == "" { + continue + } + if err := os.MkdirAll(dir, 0o755); err != nil { + return fmt.Errorf("mkdir %s: %w", dir, err) + } + } + return nil +} + +// ensureSigningKey loads the default key from the DB or generates one. +func ensureSigningKey(ctx context.Context, st *store.Store, cfg config.Config) (*gpg.Key, error) { + existing, err := st.GetDefaultGPGKey(ctx) + if err == nil { + key, err := gpg.ParseArmoredPrivate(existing.PrivateKeyArmored) + if err != nil { + return nil, fmt.Errorf("parse stored key: %w", err) + } + return key, nil + } + if !errors.Is(err, store.ErrNotFound) { + return nil, fmt.Errorf("load key: %w", err) + } + + key, err := gpg.GenerateKey(cfg.SigningKeyUserID, cfg.SigningKeyBits) + if err != nil { + return nil, fmt.Errorf("generate key: %w", err) + } + pub, err := key.ArmoredPublic() + if err != nil { + return nil, err + } + priv, err := key.ArmoredPrivate() + if err != nil { + return nil, err + } + if _, err := st.SaveGPGKey(ctx, key.Fingerprint, key.UserID, pub, priv, true); err != nil { + return nil, fmt.Errorf("save key: %w", err) + } + return key, nil +} + +// shutdownTimeout for graceful HTTP shutdown. +const shutdownTimeout = 30 * time.Second diff --git a/server/aptrepo/aptrepo.go b/server/aptrepo/aptrepo.go new file mode 100644 index 0000000..8012061 --- /dev/null +++ b/server/aptrepo/aptrepo.go @@ -0,0 +1,293 @@ +// Package aptrepo implements the APT repository endpoint served under /apt/:repo. +// It generates Release/InRelease/Packages indices on demand from the database +// (cached in memory) and streams .deb files from the content-addressed store. +// Public repositories allow anonymous reads; private repositories require HTTP +// Basic auth where the password is an API token. +package aptrepo + +import ( + "errors" + "log/slog" + "net/http" + "strings" + + "github.com/go-chi/chi/v5" + + "urapt/server/auth" + "urapt/server/cache" + "urapt/server/middleware" + "urapt/server/store" + "urapt/shared/apt" + "urapt/shared/gpg" + "urapt/shared/httputil" + "urapt/shared/models" +) + +// APTRepo is the APT endpoint handler. +type APTRepo struct { + Store *store.Store + Auth *auth.Service + Signer *gpg.Key + Cache *cache.IndexCache +} + +// New returns the APT endpoint http.Handler (to be mounted at /apt). +func New(st *store.Store, authSvc *auth.Service, signer *gpg.Key, c *cache.IndexCache) http.Handler { + a := &APTRepo{Store: st, Auth: authSvc, Signer: signer, Cache: c} + + r := chi.NewRouter() + r.Use(middleware.Recover) + r.Use(middleware.Log) + + r.Get("/{repo}/dists/{suite}/InRelease", a.InRelease) + r.Get("/{repo}/dists/{suite}/Release", a.Release) + r.Get("/{repo}/dists/{suite}/Release.gpg", a.ReleaseGpg) + r.Get("/{repo}/dists/{suite}/{component}/{binary}/Packages", a.Packages) + r.Get("/{repo}/dists/{suite}/{component}/{binary}/Packages.gz", a.PackagesGz) + r.Get("/{repo}/dists/{suite}/{component}/{binary}/Packages.xz", a.PackagesXz) + r.Get("/{repo}/pool/{component}/{letter}/{src}/{filename}", a.Pool) + + return r +} + +// authorize loads the repository and enforces read access. For public repos +// anonymous access is allowed; for private repos HTTP Basic (password = token) +// is required. Returns the repo and true on success; on failure an error has +// been written. +func (a *APTRepo) authorize(w http.ResponseWriter, r *http.Request) (*models.Repository, bool) { + repo, err := a.Store.GetRepositoryByName(r.Context(), r.PathValue("repo")) + if err != nil { + if errors.Is(err, store.ErrNotFound) { + httputil.WriteError(w, http.StatusNotFound, httputil.CodeNotFound, "repository not found") + return nil, false + } + httputil.WriteError(w, http.StatusInternalServerError, httputil.CodeInternal, "failed to read repository") + return nil, false + } + + if repo.Visibility == models.VisibilityPublic { + return repo, true + } + + // Private repository: require HTTP Basic with token as password. + id, err := a.Auth.ResolveBasic(r.Context(), r.Header) + if err != nil { + httputil.ChallengeBasic(w, "urapt "+repo.Name) + return nil, false + } + ok, err := a.Auth.CanRead(r.Context(), id.User, repo) + if err != nil { + httputil.WriteError(w, http.StatusInternalServerError, httputil.CodeInternal, "permission check failed") + return nil, false + } + if !ok { + httputil.ChallengeBasic(w, "urapt "+repo.Name) + return nil, false + } + return repo, true +} + +// loadSuite returns the generated indices for (repo, suite), building and +// caching them on first access or after invalidation. +func (a *APTRepo) loadSuite(r *http.Request, repo *models.Repository, suiteName string) (*apt.Indices, error) { + if idx := a.Cache.Get(repo.ID, suiteName); idx != nil { + return idx, nil + } + + dist, err := a.Store.GetDistributionByName(r.Context(), repo.ID, suiteName) + if err != nil { + return nil, errMiss{err} + } + comps, err := a.Store.ListComponents(r.Context(), dist.ID) + if err != nil { + return nil, err + } + arches, err := a.Store.ListArchitectures(r.Context(), dist.ID) + if err != nil { + return nil, err + } + suitePkgs, err := a.Store.ListSuitePackages(r.Context(), repo.ID, dist.ID) + if err != nil { + return nil, err + } + + componentNames := make([]string, 0, len(comps)) + for _, c := range comps { + componentNames = append(componentNames, c.Name) + } + archNames := make([]string, 0, len(arches)) + for _, a2 := range arches { + archNames = append(archNames, a2.Name) + } + rows := make([]apt.PackageRow, 0, len(suitePkgs)) + for _, sp := range suitePkgs { + rows = append(rows, apt.PackageRow{ + Component: sp.ComponentName, + Name: sp.Name, + Version: sp.Version, + Architecture: sp.Architecture, + PoolPath: sp.PoolPath, + Size: sp.Size, + MD5sum: sp.MD5sum, + SHA1: sp.SHA1, + SHA256: sp.SHA256, + DescriptionMD5: sp.DescriptionMD5, + RawControl: sp.RawControl, + }) + } + + suite := &apt.Suite{ + Origin: "urapt " + repo.Name, + Label: "urapt " + repo.Name, + Suite: dist.Name, + Description: repo.Description, + Components: componentNames, + Architectures: archNames, + Packages: rows, + } + idx, err := apt.Generate(suite, a.Signer) + if err != nil { + return nil, err + } + a.Cache.Put(repo.ID, suiteName, idx) + return idx, nil +} + +// errMiss wraps a not-found error from a sub-load. +type errMiss struct{ err error } + +func (e errMiss) Error() string { return e.err.Error() } +func (e errMiss) Unwrap() error { return e.err } + +// indicesOrWrite loads indices and writes a 404/500 on failure. +func (a *APTRepo) indicesOrWrite(w http.ResponseWriter, r *http.Request, repo *models.Repository, suite string) *apt.Indices { + idx, err := a.loadSuite(r, repo, suite) + if err != nil { + if errors.Is(err, store.ErrNotFound) { + httputil.WriteError(w, http.StatusNotFound, httputil.CodeNotFound, "suite not found") + return nil + } + slog.Error("apt generate failed", "err", err, "repo", repo.Name, "suite", suite) + httputil.WriteError(w, http.StatusInternalServerError, httputil.CodeInternal, "failed to generate indices") + return nil + } + return idx +} + +// Release serves the unsigned Release file. +func (a *APTRepo) Release(w http.ResponseWriter, r *http.Request) { + repo, ok := a.authorize(w, r) + if !ok { + return + } + idx := a.indicesOrWrite(w, r, repo, r.PathValue("suite")) + if idx == nil { + return + } + writeBytes(w, "text/plain; charset=utf-8", idx.Release) +} + +// InRelease serves the clearsigned InRelease file. +func (a *APTRepo) InRelease(w http.ResponseWriter, r *http.Request) { + repo, ok := a.authorize(w, r) + if !ok { + return + } + idx := a.indicesOrWrite(w, r, repo, r.PathValue("suite")) + if idx == nil { + return + } + if len(idx.InRelease) == 0 { + httputil.WriteError(w, http.StatusServiceUnavailable, httputil.CodeInternal, "indices not signed") + return + } + writeBytes(w, "text/plain; charset=utf-8", idx.InRelease) +} + +// ReleaseGpg serves the detached signature Release.gpg. +func (a *APTRepo) ReleaseGpg(w http.ResponseWriter, r *http.Request) { + repo, ok := a.authorize(w, r) + if !ok { + return + } + idx := a.indicesOrWrite(w, r, repo, r.PathValue("suite")) + if idx == nil { + return + } + if len(idx.ReleaseGpg) == 0 { + httputil.WriteError(w, http.StatusServiceUnavailable, httputil.CodeInternal, "indices not signed") + return + } + writeBytes(w, "application/pgp-signature", idx.ReleaseGpg) +} + +// Packages serves the Packages index for a (component, arch). +func (a *APTRepo) Packages(w http.ResponseWriter, r *http.Request) { + a.servePackages(w, r, "") +} +func (a *APTRepo) PackagesGz(w http.ResponseWriter, r *http.Request) { + a.servePackages(w, r, "gz") +} +func (a *APTRepo) PackagesXz(w http.ResponseWriter, r *http.Request) { + a.servePackages(w, r, "xz") +} + +func (a *APTRepo) servePackages(w http.ResponseWriter, r *http.Request, encoding string) { + repo, ok := a.authorize(w, r) + if !ok { + return + } + idx := a.indicesOrWrite(w, r, repo, r.PathValue("suite")) + if idx == nil { + return + } + binary := r.PathValue("binary") + if !strings.HasPrefix(binary, "binary-") { + httputil.WriteError(w, http.StatusNotFound, httputil.CodeNotFound, "not found") + return + } + arch := strings.TrimPrefix(binary, "binary-") + relPath := r.PathValue("component") + "/binary-" + arch + "/Packages" + var data []byte + var contentType string + switch encoding { + case "": + data = idx.Packages[relPath] + contentType = "text/plain; charset=utf-8" + case "gz": + data = idx.PackagesGz[relPath+".gz"] + contentType = "application/gzip" + case "xz": + data = idx.PackagesXz[relPath+".xz"] + contentType = "application/x-xz" + } + if data == nil { + httputil.WriteError(w, http.StatusNotFound, httputil.CodeNotFound, "index not found") + return + } + writeBytes(w, contentType, data) +} + +// Pool streams a .deb file from the content-addressed store. +func (a *APTRepo) Pool(w http.ResponseWriter, r *http.Request) { + repo, ok := a.authorize(w, r) + if !ok { + return + } + poolPath := strings.Join([]string{ + "pool", r.PathValue("component"), r.PathValue("letter"), r.PathValue("src"), r.PathValue("filename"), + }, "/") + pkg, err := a.Store.GetPackageByPoolPath(r.Context(), repo.ID, poolPath) + if err != nil { + httputil.WriteError(w, http.StatusNotFound, httputil.CodeNotFound, "package not found") + return + } + http.ServeFile(w, r, pkg.Filename) +} + +// writeBytes writes raw bytes with a content type and 200 status. +func writeBytes(w http.ResponseWriter, contentType string, data []byte) { + w.Header().Set("Content-Type", contentType) + w.WriteHeader(http.StatusOK) + _, _ = w.Write(data) +} diff --git a/server/aptrepo/aptrepo_test.go b/server/aptrepo/aptrepo_test.go new file mode 100644 index 0000000..ec8afbe --- /dev/null +++ b/server/aptrepo/aptrepo_test.go @@ -0,0 +1,195 @@ +package aptrepo_test + +import ( + "bytes" + "context" + "net/http" + "net/http/httptest" + "path/filepath" + "testing" + + "github.com/go-chi/chi/v5" + + "urapt/server/aptrepo" + "urapt/server/auth" + "urapt/server/cache" + "urapt/server/restapi" + "urapt/server/store" + "urapt/shared/config" + "urapt/shared/db" + "urapt/shared/gpg" +) + +// newServer spins up a REST + APT server backed by a temp DB and store, with a +// pre-created owner token. It returns the server, the owner token, and the +// armored public key. +func newServer(t *testing.T) (*httptest.Server, string, string) { + t.Helper() + dir := t.TempDir() + database, err := db.Open(filepath.Join(dir, "test.db")) + if err != nil { + t.Fatalf("db open: %v", err) + } + t.Cleanup(func() { database.Close() }) + st := store.New(database) + authSvc := auth.NewService(st) + + key, err := gpg.GenerateKey("urapt-test ", 2048) + if err != nil { + t.Fatalf("gpg key: %v", err) + } + sp := &testSigner{key: key} + cfg := config.Defaults + cfg.StoreDir = dir + cfg.PackagesDir = filepath.Join(dir, "packages") + cfg.DBPath = filepath.Join(dir, "test.db") + if err := mkdirAll(cfg.PackagesDir); err != nil { + t.Fatalf("mkdir pkg dir: %v", err) + } + + idxCache := cache.New() + root := chi.NewRouter() + root.Mount("/api/v1", restapi.New(st, authSvc, sp, &cfg, idxCache)) + root.Mount("/apt", aptrepo.New(st, authSvc, key, idxCache)) + srv := httptest.NewServer(root) + t.Cleanup(srv.Close) + + // Register owner. + body := post(t, srv, "/api/v1/auth/register", "", map[string]string{"username": "owner", "password": "supersecret"}) + token := str(body, "token") + pub, err := key.ArmoredPublic() + if err != nil { + t.Fatalf("armored public: %v", err) + } + return srv, token, pub +} + +type testSigner struct{ key *gpg.Key } + +func (s *testSigner) PublicKeyArmored() (string, error) { return s.key.ArmoredPublic() } +func (s *testSigner) Fingerprint() string { return s.key.Fingerprint } + +func post(t *testing.T, srv *httptest.Server, path, token string, body any) []byte { + t.Helper() + b := jsonMarshal(body) + req, _ := http.NewRequest("POST", srv.URL+path, bytes.NewReader(b)) + req.Header.Set("Content-Type", "application/json") + if token != "" { + req.Header.Set("Authorization", "Bearer "+token) + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("post %s: %v", path, err) + } + defer resp.Body.Close() + return readAll(resp.Body) +} + +func get(t *testing.T, srv *httptest.Server, path, token string) (int, []byte) { + t.Helper() + req, _ := http.NewRequest("GET", srv.URL+path, nil) + if token != "" { + req.Header.Set("Authorization", "Bearer "+token) + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("get %s: %v", path, err) + } + defer resp.Body.Close() + return resp.StatusCode, readAll(resp.Body) +} + +func setupRepo(t *testing.T, srv *httptest.Server, token string) { + post(t, srv, "/api/v1/repositories", token, map[string]any{"name": "myrepo", "visibility": "public"}) + post(t, srv, "/api/v1/repositories/myrepo/distributions", token, map[string]string{"name": "stable"}) + post(t, srv, "/api/v1/repositories/myrepo/distributions/stable/components", token, map[string]string{"name": "main"}) + post(t, srv, "/api/v1/repositories/myrepo/distributions/stable/architectures", token, map[string]string{"name": "amd64"}) +} + +func TestAPTIndicesAndPool(t *testing.T) { + srv, token, pub := newServer(t) + setupRepo(t, srv, token) + + // Push a real .deb via the REST API. + debBytes := buildDeb("foo", "1.0", "amd64") + upload(t, srv, token, "myrepo", "stable", "main", "foo_1.0_amd64.deb", debBytes) + + // Fetch the Packages index. + code, body := get(t, srv, "/apt/myrepo/dists/stable/main/binary-amd64/Packages", "") + if code != 200 { + t.Fatalf("Packages status %d body %s", code, body) + } + if !bytes.Contains(body, []byte("Package: foo")) || !bytes.Contains(body, []byte("Filename: pool/main/f/foo/foo_1.0_amd64.deb")) { + t.Fatalf("Packages index missing entry:\n%s", body) + } + + // Fetch Release. + code, rel := get(t, srv, "/apt/myrepo/dists/stable/Release", "") + if code != 200 { + t.Fatalf("Release status %d", code) + } + if !bytes.Contains(rel, []byte("Suite: stable")) || !bytes.Contains(rel, []byte("Components: main")) { + t.Fatalf("Release missing fields:\n%s", rel) + } + + // Fetch InRelease (clearsigned) and verify against the public key. + code, inrel := get(t, srv, "/apt/myrepo/dists/stable/InRelease", "") + if code != 200 { + t.Fatalf("InRelease status %d", code) + } + if !bytes.Contains(inrel, []byte("BEGIN PGP SIGNED MESSAGE")) { + t.Fatalf("InRelease not clearsigned:\n%s", inrel) + } + if _, err := gpg.VerifyClearSign(pub, inrel); err != nil { + t.Fatalf("verify InRelease: %v", err) + } + + // Fetch Release.gpg and verify as a detached signature of Release. + code, sig := get(t, srv, "/apt/myrepo/dists/stable/Release.gpg", "") + if code != 200 { + t.Fatalf("Release.gpg status %d", code) + } + if err := gpg.VerifyDetached(pub, rel, sig); err != nil { + t.Fatalf("verify Release.gpg: %v", err) + } + + // Fetch the pool .deb and compare bytes. + code, deb := get(t, srv, "/apt/myrepo/pool/main/f/foo/foo_1.0_amd64.deb", "") + if code != 200 { + t.Fatalf("pool status %d", code) + } + if !bytes.Equal(deb, debBytes) { + t.Fatalf("pool bytes mismatch (%d vs %d)", len(deb), len(debBytes)) + } +} + +func TestPrivateRepoRequiresAuth(t *testing.T) { + srv, token, _ := newServer(t) + // private repo + post(t, srv, "/api/v1/repositories", token, map[string]any{"name": "priv", "visibility": "private"}) + post(t, srv, "/api/v1/repositories/priv/distributions", token, map[string]string{"name": "stable"}) + post(t, srv, "/api/v1/repositories/priv/distributions/stable/components", token, map[string]string{"name": "main"}) + post(t, srv, "/api/v1/repositories/priv/distributions/stable/architectures", token, map[string]string{"name": "amd64"}) + upload(t, srv, token, "priv", "stable", "main", "foo_1.0_amd64.deb", buildDeb("foo", "1.0", "amd64")) + + // anonymous → 401 + code, _ := get(t, srv, "/apt/priv/dists/stable/Release", "") + if code != 401 { + t.Fatalf("anonymous private repo should be 401, got %d", code) + } + + // with token as basic auth password → 200 + req, _ := http.NewRequest("GET", srv.URL+"/apt/priv/dists/stable/Release", nil) + req.SetBasicAuth("owner", token) + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("get: %v", err) + } + resp.Body.Close() + if resp.StatusCode != 200 { + t.Fatalf("authenticated private repo should be 200, got %d", resp.StatusCode) + } +} + +// keep context import used +var _ = context.Background diff --git a/server/aptrepo/helpers_test.go b/server/aptrepo/helpers_test.go new file mode 100644 index 0000000..1a35bbd --- /dev/null +++ b/server/aptrepo/helpers_test.go @@ -0,0 +1,126 @@ +package aptrepo_test + +import ( + "archive/tar" + "bytes" + "compress/gzip" + "encoding/json" + "io" + "mime/multipart" + "net/http" + "net/http/httptest" + "os" + "testing" +) + +func jsonMarshal(v any) []byte { + b, _ := json.Marshal(v) + return b +} + +func readAll(r io.Reader) []byte { + b, _ := io.ReadAll(r) + return b +} + +func mkdirAll(dir string) error { return os.MkdirAll(dir, 0o755) } + +func str(body []byte, key string) string { + var m map[string]any + _ = json.Unmarshal(body, &m) + if v, ok := m[key].(string); ok { + return v + } + return "" +} + +// buildDeb constructs a minimal valid .deb with the given package/version/arch. +func buildDeb(name, version, arch string) []byte { + control := "Package: " + name + "\nVersion: " + version + "\nArchitecture: " + arch + "\nMaintainer: t \nDescription: short\n extended\n" + var ctrlBuf bytes.Buffer + gz := gzip.NewWriter(&ctrlBuf) + tw := tar.NewWriter(gz) + writeTw(tw, "control", control) + tw.Close() + gz.Close() + + var dataBuf bytes.Buffer + gz2 := gzip.NewWriter(&dataBuf) + tw2 := tar.NewWriter(gz2) + writeTw(tw2, "usr/share/"+name, "x") + tw2.Close() + gz2.Close() + + var out bytes.Buffer + out.WriteString("!\n") + writeAr(&out, "debian-binary", []byte("2.0\n")) + writeAr(&out, "control.tar.gz", ctrlBuf.Bytes()) + writeAr(&out, "data.tar.gz", dataBuf.Bytes()) + return out.Bytes() +} + +func writeTw(tw *tar.Writer, name, body string) { + _ = tw.WriteHeader(&tar.Header{Name: name, Mode: 0o644, Size: int64(len(body)), Typeflag: tar.TypeReg}) + _, _ = tw.Write([]byte(body)) +} + +func writeAr(buf *bytes.Buffer, name string, data []byte) { + header := make([]byte, 60) + for i := range header { + header[i] = ' ' + } + copy(header[0:], name+"/") + ds := []byte(padNum(len(data), 10)) + copy(header[48:], ds) + header[58] = '`' + header[59] = '\n' + buf.Write(header) + buf.Write(data) + if len(data)%2 == 1 { + buf.WriteByte('\n') + } +} + +func padNum(n, width int) string { + s := make([]byte, width) + for i := range s { + s[i] = ' ' + } + digits := []byte(itoa(n)) + copy(s[len(s)-len(digits):], digits) + return string(s) +} + +func itoa(n int) string { + if n == 0 { + return "0" + } + var b []byte + for n > 0 { + b = append([]byte{byte('0' + n%10)}, b...) + n /= 10 + } + return string(b) +} + +func upload(t *testing.T, srv *httptest.Server, token, repo, dist, component, filename string, deb []byte) { + t.Helper() + var buf bytes.Buffer + mw := multipart.NewWriter(&buf) + _ = mw.WriteField("component", component) + fw, _ := mw.CreateFormFile("file", filename) + _, _ = fw.Write(deb) + _ = mw.Close() + + req, _ := http.NewRequest("POST", srv.URL+"/api/v1/repositories/"+repo+"/distributions/"+dist+"/packages", &buf) + req.Header.Set("Content-Type", mw.FormDataContentType()) + req.Header.Set("Authorization", "Bearer "+token) + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("upload: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != 201 { + t.Fatalf("upload status %d", resp.StatusCode) + } +} diff --git a/server/auth/auth.go b/server/auth/auth.go new file mode 100644 index 0000000..25900a5 --- /dev/null +++ b/server/auth/auth.go @@ -0,0 +1,135 @@ +// Package auth implements identity resolution (bearer/basic) and the +// repository-scoped permission checks used by the REST API and the APT +// endpoint. +package auth + +import ( + "context" + "errors" + "net/http" + + "urapt/server/store" + "urapt/shared/crypto" + "urapt/shared/httputil" + "urapt/shared/models" +) + +// Identity is the resolved caller: a user and (for REST) the token used. +type Identity struct { + User *models.User + TokenID string +} + +// Service resolves identities and answers permission questions. +type Service struct { + store *store.Store +} + +// NewService constructs an auth Service. +func NewService(s *store.Store) *Service { + return &Service{store: s} +} + +// ErrUnauthenticated is returned when no valid identity can be established. +var ErrUnauthenticated = errors.New("unauthenticated") + +// ResolveBearer resolves an "Authorization: Bearer " header to an +// identity. Returns ErrUnauthenticated if absent or invalid. +func (s *Service) ResolveBearer(ctx context.Context, h http.Header) (*Identity, error) { + token, ok := httputil.ParseBearer(h) + if !ok { + return nil, ErrUnauthenticated + } + return s.resolveToken(ctx, token) +} + +// ResolveBasic resolves an "Authorization: Basic" header where the password is +// an API token. Returns ErrUnauthenticated if absent/invalid. +func (s *Service) ResolveBasic(ctx context.Context, h http.Header) (*Identity, error) { + _, password, ok := httputil.ParseBasic(h) + if !ok { + return nil, ErrUnauthenticated + } + return s.resolveToken(ctx, password) +} + +func (s *Service) resolveToken(ctx context.Context, token string) (*Identity, error) { + if token == "" { + return nil, ErrUnauthenticated + } + hash := crypto.HashToken(token) + t, err := s.store.GetTokenByHash(ctx, hash) + if err != nil { + if errors.Is(err, store.ErrNotFound) { + return nil, ErrUnauthenticated + } + return nil, err + } + user, err := s.store.GetUserByID(ctx, t.UserID) + if err != nil { + return nil, ErrUnauthenticated + } + _ = s.store.TouchToken(ctx, t.ID) + return &Identity{User: user, TokenID: t.ID}, nil +} + +// CanRead reports whether user may read (download/list within) repo. +func (s *Service) CanRead(ctx context.Context, user *models.User, repo *models.Repository) (bool, error) { + if user == nil { + return repo.Visibility == models.VisibilityPublic, nil + } + if user.IsAdmin || repo.OwnerUserID == user.ID { + return true, nil + } + access, ok, err := s.store.GetMemberAccess(ctx, repo.ID, user.ID) + if err != nil { + return false, err + } + if ok { + switch access { + case models.AccessRead, models.AccessReadWrite, models.AccessAdmin: + return true, nil + } + } + return repo.Visibility == models.VisibilityPublic, nil +} + +// CanWrite reports whether user may push packages to repo. +func (s *Service) CanWrite(ctx context.Context, user *models.User, repo *models.Repository) (bool, error) { + if user == nil { + return false, nil + } + if user.IsAdmin || repo.OwnerUserID == user.ID { + return true, nil + } + access, ok, err := s.store.GetMemberAccess(ctx, repo.ID, user.ID) + if err != nil { + return false, err + } + if !ok { + return false, nil + } + switch access { + case models.AccessWrite, models.AccessReadWrite, models.AccessAdmin: + return true, nil + } + return false, nil +} + +// CanManage reports whether user may manage repo settings and members. +func (s *Service) CanManage(ctx context.Context, user *models.User, repo *models.Repository) (bool, error) { + if user == nil { + return false, nil + } + if user.IsAdmin || repo.OwnerUserID == user.ID { + return true, nil + } + access, ok, err := s.store.GetMemberAccess(ctx, repo.ID, user.ID) + if err != nil { + return false, err + } + if !ok { + return false, nil + } + return access == models.AccessAdmin, nil +} diff --git a/server/auth/auth_test.go b/server/auth/auth_test.go new file mode 100644 index 0000000..bcb2c81 --- /dev/null +++ b/server/auth/auth_test.go @@ -0,0 +1,333 @@ +package auth + +import ( + "context" + "encoding/base64" + "errors" + "net/http" + "path/filepath" + "testing" + + "urapt/server/store" + "urapt/shared/crypto" + "urapt/shared/db" + "urapt/shared/models" +) + +// newAuthSvc opens a fresh DB and returns an auth Service plus a handle to the +// underlying store for seeding users/repos/tokens. +func newAuthSvc(t *testing.T) (*Service, *store.Store) { + t.Helper() + dir := t.TempDir() + database, err := db.Open(filepath.Join(dir, "test.db")) + if err != nil { + t.Fatalf("db open: %v", err) + } + t.Cleanup(func() { database.Close() }) + st := store.New(database) + return NewService(st), st +} + +// seedUser creates a user via the store and returns it. +func seedUser(t *testing.T, st *store.Store, ctx context.Context, username string) *models.User { + t.Helper() + hash, err := crypto.HashPassword("pw") + if err != nil { + t.Fatalf("hash: %v", err) + } + u, _, err := st.CreateUser(ctx, username, hash) + if err != nil { + t.Fatalf("create user %q: %v", username, err) + } + return u +} + +// seedToken mints a real token for userID and returns the plaintext. +func seedToken(t *testing.T, st *store.Store, ctx context.Context, userID, name string) string { + t.Helper() + tok, hash, prefix, err := crypto.GenerateToken() + if err != nil { + t.Fatalf("generate: %v", err) + } + if _, err := st.CreateToken(ctx, userID, name, prefix, hash); err != nil { + t.Fatalf("create token: %v", err) + } + return tok +} + +func seedRepo(t *testing.T, st *store.Store, ctx context.Context, name, ownerID string, vis models.Visibility) *models.Repository { + t.Helper() + r, err := st.CreateRepository(ctx, name, ownerID, vis, "") + if err != nil { + t.Fatalf("create repo %q: %v", name, err) + } + return r +} + +// seedAdmin creates a non-bootstrap user and promotes them to server admin, +// returning a refreshed user record with IsAdmin=true. +func seedAdmin(t *testing.T, st *store.Store, ctx context.Context, username string) *models.User { + t.Helper() + u := seedUser(t, st, ctx, username) + if err := st.UpdateUser(ctx, u.ID, boolPtr(true)); err != nil { + t.Fatalf("promote %q: %v", username, err) + } + refreshed, err := st.GetUserByID(ctx, u.ID) + if err != nil { + t.Fatalf("refetch admin: %v", err) + } + return refreshed +} + +func bearerHeader(token string) http.Header { + h := http.Header{} + h.Set("Authorization", "Bearer "+token) + return h +} + +func basicHeader(username, password string) http.Header { + h := http.Header{} + enc := base64.StdEncoding.EncodeToString([]byte(username + ":" + password)) + h.Set("Authorization", "Basic "+enc) + return h +} + +// --- ResolveBearer --- + +func TestResolveBearer_Valid(t *testing.T) { + ctx := context.Background() + svc, st := newAuthSvc(t) + u := seedUser(t, st, ctx, "alice") + tok := seedToken(t, st, ctx, u.ID, "laptop") + + id, err := svc.ResolveBearer(ctx, bearerHeader(tok)) + if err != nil { + t.Fatalf("resolve: %v", err) + } + if id.User == nil || id.User.ID != u.ID { + t.Fatalf("identity = %+v", id) + } + if id.TokenID == "" { + t.Fatal("token id should be set") + } +} + +func TestResolveBearer_MissingHeader(t *testing.T) { + ctx := context.Background() + svc, _ := newAuthSvc(t) + _, err := svc.ResolveBearer(ctx, http.Header{}) + if !errors.Is(err, ErrUnauthenticated) { + t.Fatalf("expected ErrUnauthenticated, got %v", err) + } +} + +func TestResolveBearer_MalformedHeader(t *testing.T) { + ctx := context.Background() + svc, _ := newAuthSvc(t) + h := http.Header{} + h.Set("Authorization", "Bearer") // no token + if _, err := svc.ResolveBearer(ctx, h); !errors.Is(err, ErrUnauthenticated) { + t.Fatalf("expected ErrUnauthenticated, got %v", err) + } + h.Set("Authorization", "Basic abc") + if _, err := svc.ResolveBearer(ctx, h); !errors.Is(err, ErrUnauthenticated) { + t.Fatalf("expected ErrUnauthenticated for non-Bearer scheme, got %v", err) + } +} + +func TestResolveBearer_InvalidToken(t *testing.T) { + ctx := context.Background() + svc, _ := newAuthSvc(t) + if _, err := svc.ResolveBearer(ctx, bearerHeader("urapt_notreal")); !errors.Is(err, ErrUnauthenticated) { + t.Fatalf("expected ErrUnauthenticated for bogus token, got %v", err) + } +} + +func TestResolveBearer_RevokedToken(t *testing.T) { + ctx := context.Background() + svc, st := newAuthSvc(t) + u := seedUser(t, st, ctx, "alice") + tok := seedToken(t, st, ctx, u.ID, "laptop") + // Revoke by hash (simulating logout). + _ = st.RevokeTokenByHash(ctx, crypto.HashToken(tok)) + + if _, err := svc.ResolveBearer(ctx, bearerHeader(tok)); !errors.Is(err, ErrUnauthenticated) { + t.Fatalf("expected ErrUnauthenticated for revoked token, got %v", err) + } +} + +// --- ResolveBasic --- + +func TestResolveBasic_Valid(t *testing.T) { + ctx := context.Background() + svc, st := newAuthSvc(t) + u := seedUser(t, st, ctx, "alice") + tok := seedToken(t, st, ctx, u.ID, "laptop") + + // Username is ignored; password is the token. + id, err := svc.ResolveBasic(ctx, basicHeader("anything", tok)) + if err != nil { + t.Fatalf("resolve: %v", err) + } + if id.User.ID != u.ID { + t.Fatalf("user id mismatch") + } +} + +func TestResolveBasic_MissingAndMalformed(t *testing.T) { + ctx := context.Background() + svc, _ := newAuthSvc(t) + if _, err := svc.ResolveBasic(ctx, http.Header{}); !errors.Is(err, ErrUnauthenticated) { + t.Fatalf("missing header: expected ErrUnauthenticated, got %v", err) + } + h := http.Header{} + h.Set("Authorization", "Basic not-base64!!!") + if _, err := svc.ResolveBasic(ctx, h); !errors.Is(err, ErrUnauthenticated) { + t.Fatalf("bad base64: expected ErrUnauthenticated, got %v", err) + } + h.Set("Authorization", "Basic "+base64.StdEncoding.EncodeToString([]byte("noseparator"))) + if _, err := svc.ResolveBasic(ctx, h); !errors.Is(err, ErrUnauthenticated) { + t.Fatalf("no colon: expected ErrUnauthenticated, got %v", err) + } +} + +// --- CanRead --- + +func TestCanRead(t *testing.T) { + ctx := context.Background() + svc, st := newAuthSvc(t) + owner := seedUser(t, st, ctx, "owner") + member := seedUser(t, st, ctx, "member") + nonMember := seedUser(t, st, ctx, "stranger") + admin := seedAdmin(t, st, ctx, "admin") + + pubRepo := seedRepo(t, st, ctx, "pub", owner.ID, models.VisibilityPublic) + privRepo := seedRepo(t, st, ctx, "priv", owner.ID, models.VisibilityPrivate) + _ = st.AddMember(ctx, privRepo.ID, member.ID, models.AccessRead) + + cases := []struct { + name string + user *models.User + repo *models.Repository + want bool + }{ + {"nil user, public repo", nil, pubRepo, true}, + {"nil user, private repo", nil, privRepo, false}, + {"admin on private", admin, privRepo, true}, + {"owner on private", owner, privRepo, true}, + {"member(read) on private", member, privRepo, true}, + {"non-member on public", nonMember, pubRepo, true}, + {"non-member on private", nonMember, privRepo, false}, + } + for _, c := range cases { + got, err := svc.CanRead(ctx, c.user, c.repo) + if err != nil { + t.Fatalf("%s: error %v", c.name, err) + } + if got != c.want { + t.Errorf("%s: got %v want %v", c.name, got, c.want) + } + } +} + +func TestCanRead_WriteOnlyMemberCanRead(t *testing.T) { + // A write-only member should still be able to read (the AccessWrite grant + // does not include read in the switch in CanRead, so this confirms the + // public-fallback behavior: a private repo would deny a write-only member + // read access). + ctx := context.Background() + svc, st := newAuthSvc(t) + owner := seedUser(t, st, ctx, "owner") + writer := seedUser(t, st, ctx, "writer") + privRepo := seedRepo(t, st, ctx, "priv", owner.ID, models.VisibilityPrivate) + _ = st.AddMember(ctx, privRepo.ID, writer.ID, models.AccessWrite) + + got, _ := svc.CanRead(ctx, writer, privRepo) + if got { + t.Fatal("write-only member should NOT be able to read a private repo") + } +} + +// --- CanWrite --- + +func TestCanWrite(t *testing.T) { + ctx := context.Background() + svc, st := newAuthSvc(t) + owner := seedUser(t, st, ctx, "owner") + reader := seedUser(t, st, ctx, "reader") + writer := seedUser(t, st, ctx, "writer") + rw := seedUser(t, st, ctx, "rw") + repoAdmin := seedUser(t, st, ctx, "repoadmin") + nonMember := seedUser(t, st, ctx, "stranger") + admin := seedAdmin(t, st, ctx, "admin") + + repo := seedRepo(t, st, ctx, "repo", owner.ID, models.VisibilityPublic) + _ = st.AddMember(ctx, repo.ID, reader.ID, models.AccessRead) + _ = st.AddMember(ctx, repo.ID, writer.ID, models.AccessWrite) + _ = st.AddMember(ctx, repo.ID, rw.ID, models.AccessReadWrite) + _ = st.AddMember(ctx, repo.ID, repoAdmin.ID, models.AccessAdmin) + + cases := []struct { + name string + user *models.User + want bool + }{ + {"nil user", nil, false}, + {"admin", admin, true}, + {"owner", owner, true}, + {"read member", reader, false}, + {"write member", writer, true}, + {"read-write member", rw, true}, + {"repo admin member", repoAdmin, true}, + {"non-member", nonMember, false}, + } + for _, c := range cases { + got, err := svc.CanWrite(ctx, c.user, repo) + if err != nil { + t.Fatalf("%s: error %v", c.name, err) + } + if got != c.want { + t.Errorf("%s: got %v want %v", c.name, got, c.want) + } + } +} + +// --- CanManage --- + +func TestCanManage(t *testing.T) { + ctx := context.Background() + svc, st := newAuthSvc(t) + owner := seedUser(t, st, ctx, "owner") + reader := seedUser(t, st, ctx, "reader") + repoAdmin := seedUser(t, st, ctx, "repoadmin") + nonMember := seedUser(t, st, ctx, "stranger") + admin := seedAdmin(t, st, ctx, "admin") + + repo := seedRepo(t, st, ctx, "repo", owner.ID, models.VisibilityPublic) + _ = st.AddMember(ctx, repo.ID, reader.ID, models.AccessRead) + _ = st.AddMember(ctx, repo.ID, repoAdmin.ID, models.AccessAdmin) + + cases := []struct { + name string + user *models.User + want bool + }{ + {"nil user", nil, false}, + {"admin", admin, true}, + {"owner", owner, true}, + {"read member", reader, false}, + {"repo admin member", repoAdmin, true}, + {"non-member", nonMember, false}, + } + for _, c := range cases { + got, err := svc.CanManage(ctx, c.user, repo) + if err != nil { + t.Fatalf("%s: error %v", c.name, err) + } + if got != c.want { + t.Errorf("%s: got %v want %v", c.name, got, c.want) + } + } +} + +func boolPtr(b bool) *bool { return &b } diff --git a/server/cache/cache.go b/server/cache/cache.go new file mode 100644 index 0000000..069ac42 --- /dev/null +++ b/server/cache/cache.go @@ -0,0 +1,79 @@ +// Package cache defines the in-memory APT index cache contract used by the +// REST API (to invalidate on mutation) and the APT endpoint (to serve cached +// indices). The implementation lives in this package; it is regenerated lazily +// after invalidation or restart. +package cache + +import ( + "sync" + + "urapt/shared/apt" +) + +// IndexCache holds generated APT indices per (repository, suite), keyed and +// versioned so that mutations invalidate lazily. +type IndexCache struct { + mu sync.Mutex + entry map[string]*entry +} + +type entry struct { + indices *apt.Indices + suite *apt.Suite + gen int64 + dirty bool +} + +// New constructs an empty IndexCache. +func New() *IndexCache { + return &IndexCache{entry: map[string]*entry{}} +} + +// key builds the cache key. +func key(repoID, suite string) string { return repoID + "/" + suite } + +// Get returns the cached indices for (repoID, suite), or nil if not present +// or marked dirty. The suite is returned so the caller can rebuild it. +func (c *IndexCache) Get(repoID, suite string) *apt.Indices { + c.mu.Lock() + defer c.mu.Unlock() + e := c.entry[key(repoID, suite)] + if e == nil || e.dirty { + return nil + } + return e.indices +} + +// Put stores freshly generated indices for (repoID, suite). +func (c *IndexCache) Put(repoID, suite string, idx *apt.Indices) { + c.mu.Lock() + defer c.mu.Unlock() + e := c.entry[key(repoID, suite)] + if e == nil { + e = &entry{} + c.entry[key(repoID, suite)] = e + } + e.indices = idx + e.dirty = false +} + +// Invalidate marks one (repository, suite) as stale; the next read rebuilds. +func (c *IndexCache) Invalidate(repoID, suite string) { + c.mu.Lock() + defer c.mu.Unlock() + if e := c.entry[key(repoID, suite)]; e != nil { + e.dirty = true + } +} + +// InvalidateRepo marks every suite under a repository as stale. +func (c *IndexCache) InvalidateRepo(repoID string) { + c.mu.Lock() + defer c.mu.Unlock() + prefix := repoID + "/" + for k, e := range c.entry { + if len(k) > len(prefix) && k[:len(prefix)] == prefix { + e.dirty = true + } + } +} diff --git a/server/middleware/middleware.go b/server/middleware/middleware.go new file mode 100644 index 0000000..b4cb5fe --- /dev/null +++ b/server/middleware/middleware.go @@ -0,0 +1,107 @@ +// Package middleware provides HTTP middleware shared by the REST API and the +// APT endpoint: panic recovery, request logging, and bearer/basic identity +// injection. +package middleware + +import ( + "context" + "log/slog" + "net/http" + "runtime/debug" + + "urapt/server/auth" + "urapt/shared/httputil" +) + +// ctxKey is an unexported key type for context values. +type ctxKey int + +const ( + keyIdentity ctxKey = iota +) + +// IdentityFromContext returns the identity previously attached by RequireBearer +// or OptionalBearer, or nil. +func IdentityFromContext(ctx context.Context) *auth.Identity { + v, _ := ctx.Value(keyIdentity).(*auth.Identity) + return v +} + +// withIdentity stores id in the context. +func withIdentity(ctx context.Context, id *auth.Identity) context.Context { + return context.WithValue(ctx, keyIdentity, id) +} + +// Recover catches panics and renders a uniform 500. +func Recover(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + defer func() { + if rec := recover(); rec != nil { + slog.Error("panic", "err", rec, "stack", string(debug.Stack())) + httputil.WriteError(w, http.StatusInternalServerError, httputil.CodeInternal, "internal server error") + } + }() + next.ServeHTTP(w, r) + }) +} + +// Log logs each request using slog. +func Log(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + rw := &statusRecorder{ResponseWriter: w, status: http.StatusOK} + next.ServeHTTP(rw, r) + slog.Info("http", "method", r.Method, "path", r.URL.Path, "status", rw.status) + }) +} + +type statusRecorder struct { + http.ResponseWriter + status int +} + +func (s *statusRecorder) WriteHeader(code int) { + s.status = code + s.ResponseWriter.WriteHeader(code) +} + +// RequireBearer resolves a bearer token; on failure it renders 401. On success +// the identity is attached to the request context. +func RequireBearer(svc *auth.Service) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + id, err := svc.ResolveBearer(r.Context(), r.Header) + if err != nil { + httputil.WriteError(w, http.StatusUnauthorized, httputil.CodeUnauthorized, "authentication required") + return + } + next.ServeHTTP(w, r.WithContext(withIdentity(r.Context(), id))) + }) + } +} + +// OptionalBearer resolves a bearer token if present but never blocks; the +// identity (possibly nil) is attached to the context. +func OptionalBearer(svc *auth.Service) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + id, _ := svc.ResolveBearer(r.Context(), r.Header) + next.ServeHTTP(w, r.WithContext(withIdentity(r.Context(), id))) + }) + } +} + +// RequireBasic resolves HTTP Basic auth (password = API token); on failure it +// issues a 401 with a WWW-Authenticate challenge. Used by the APT endpoint for +// private repositories. +func RequireBasic(svc *auth.Service, realm string) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + id, err := svc.ResolveBasic(r.Context(), r.Header) + if err != nil { + httputil.ChallengeBasic(w, realm) + return + } + next.ServeHTTP(w, r.WithContext(withIdentity(r.Context(), id))) + }) + } +} diff --git a/server/restapi/api.go b/server/restapi/api.go new file mode 100644 index 0000000..f7575b7 --- /dev/null +++ b/server/restapi/api.go @@ -0,0 +1,133 @@ +// Package restapi implements the urapt REST API: handlers, routing, and +// request validation. It is mounted under /api/v1 on the server. +package restapi + +import ( + "context" + "net/http" + "regexp" + + "github.com/go-chi/chi/v5" + + "urapt/server/auth" + "urapt/server/cache" + "urapt/server/middleware" + "urapt/server/store" + "urapt/shared/config" + "urapt/shared/httputil" +) + +// SignerProvider returns the server's current signing key. It is supplied by +// the app; the APT endpoint also uses it to sign Release files. +type SignerProvider interface { + PublicKeyArmored() (string, error) + Fingerprint() string +} + +// API holds the dependencies shared by all REST handlers. +type API struct { + Store *store.Store + Auth *auth.Service + Signer SignerProvider + Config *config.Config + Cache *cache.IndexCache +} + +// New constructs the API and returns its http.Handler (the /api/v1 router). +func New(st *store.Store, authSvc *auth.Service, signer SignerProvider, cfg *config.Config, c *cache.IndexCache) http.Handler { + api := &API{Store: st, Auth: authSvc, Signer: signer, Config: cfg, Cache: c} + + r := chi.NewRouter() + r.Use(middleware.Recover) + r.Use(middleware.Log) + + r.Get("/server/info", api.ServerInfo) + r.Get("/server/pubkey", api.ServerPubkey) + + r.Post("/auth/register", api.Register) + r.Post("/auth/login", api.Login) + + r.Group(func(r chi.Router) { + r.Use(middleware.RequireBearer(authSvc)) + + r.Post("/auth/logout", api.Logout) + r.Get("/me", api.Me) + r.Get("/me/tokens", api.ListTokens) + r.Post("/me/tokens", api.CreateToken) + r.Delete("/me/tokens/{id}", api.RevokeToken) + + // repositories (read for visible, write for permitted) + r.Get("/repositories", api.ListRepositories) + r.Post("/repositories", api.CreateRepository) + r.Get("/repositories/{repo}", api.GetRepository) + r.Patch("/repositories/{repo}", api.UpdateRepository) + r.Delete("/repositories/{repo}", api.DeleteRepository) + r.Get("/repositories/{repo}/members", api.ListMembers) + r.Post("/repositories/{repo}/members", api.AddMember) + r.Patch("/repositories/{repo}/members/{username}", api.UpdateMember) + r.Delete("/repositories/{repo}/members/{username}", api.RemoveMember) + r.Get("/repositories/{repo}/pubkey", api.RepoPubkey) + + // structure + r.Get("/repositories/{repo}/distributions", api.ListDistributions) + r.Post("/repositories/{repo}/distributions", api.CreateDistribution) + r.Delete("/repositories/{repo}/distributions/{dist}", api.DeleteDistribution) + r.Get("/repositories/{repo}/distributions/{dist}/components", api.ListComponents) + r.Post("/repositories/{repo}/distributions/{dist}/components", api.CreateComponent) + r.Delete("/repositories/{repo}/distributions/{dist}/components/{comp}", api.DeleteComponent) + r.Get("/repositories/{repo}/distributions/{dist}/architectures", api.ListArchitectures) + r.Post("/repositories/{repo}/distributions/{dist}/architectures", api.CreateArchitecture) + r.Delete("/repositories/{repo}/distributions/{dist}/architectures/{arch}", api.DeleteArchitecture) + + // packages + r.Get("/repositories/{repo}/distributions/{dist}/packages", api.ListPackages) + r.Post("/repositories/{repo}/distributions/{dist}/packages", api.PushPackage) + r.Get("/repositories/{repo}/packages/{id}", api.GetPackage) + r.Get("/repositories/{repo}/packages/{id}/file", api.GetPackageFile) + r.Delete("/repositories/{repo}/packages/{id}", api.DeletePackage) + + r.Group(func(r chi.Router) { + r.Use(api.RequireAdmin) + r.Get("/users", api.ListUsers) + r.Get("/users/{id}", api.GetUser) + r.Patch("/users/{id}", api.UpdateUser) + r.Delete("/users/{id}", api.DeleteUser) + }) + }) + + return r +} + +// RequireAdmin is middleware that requires the caller to be a server admin. +func (api *API) RequireAdmin(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + id := middleware.IdentityFromContext(r.Context()) + if id == nil || !id.User.IsAdmin { + httputil.WriteError(w, http.StatusForbidden, httputil.CodeForbidden, "admin privileges required") + return + } + next.ServeHTTP(w, r) + }) +} + +// validation patterns. +var ( + usernameRE = regexp.MustCompile(`^[a-z0-9_-]{3,32}$`) + passwordRE = regexp.MustCompile(`^.{8,256}$`) + tokenNameRE = regexp.MustCompile(`^.{1,64}$`) +) + +// validateUsername returns true if s is an acceptable username. +func validateUsername(s string) bool { return usernameRE.MatchString(s) } + +// validatePassword returns true if s is an acceptable password. +func validatePassword(s string) bool { return passwordRE.MatchString(s) } + +// bad renders a 400 validation error. +func bad(w http.ResponseWriter, msg string) { + httputil.WriteError(w, http.StatusBadRequest, httputil.CodeBadRequest, msg) +} + +// contextKey for request-scoped values is not needed beyond middleware; this +// var keeps context imported if future handlers need it. +var _ = context.Background diff --git a/server/restapi/api_test.go b/server/restapi/api_test.go new file mode 100644 index 0000000..08ce368 --- /dev/null +++ b/server/restapi/api_test.go @@ -0,0 +1,263 @@ +package restapi + +import ( + "bytes" + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + + "github.com/go-chi/chi/v5" + + "urapt/server/auth" + "urapt/server/cache" + "urapt/server/store" + "urapt/shared/config" + "urapt/shared/db" + "urapt/shared/gpg" +) + +type harness struct { + t *testing.T + srv *httptest.Server + store *store.Store + token string + pkgDir string +} + +func newHarness(t *testing.T) *harness { + t.Helper() + dir := t.TempDir() + database, err := db.Open(filepath.Join(dir, "test.db")) + if err != nil { + t.Fatalf("db open: %v", err) + } + t.Cleanup(func() { database.Close() }) + st := store.New(database) + authSvc := auth.NewService(st) + + key, err := gpg.GenerateKey("urapt-test ", 2048) + if err != nil { + t.Fatalf("gpg key: %v", err) + } + sp := &testSigner{key: key} + cfg := config.Defaults + cfg.StoreDir = dir + cfg.PackagesDir = filepath.Join(dir, "packages") + cfg.DBPath = filepath.Join(dir, "test.db") + if err := os.MkdirAll(cfg.PackagesDir, 0o755); err != nil { + t.Fatalf("mkdir pkg dir: %v", err) + } + + h := &harness{t: t, store: st, pkgDir: cfg.PackagesDir} + mux := New(st, authSvc, sp, &cfg, cache.New()) + root := chi.NewRouter() + root.Mount("/api/v1", mux) + h.srv = httptest.NewServer(root) + t.Cleanup(h.srv.Close) + return h +} + +func (h *harness) do(method, path, token string, body any) (int, []byte) { + h.t.Helper() + var r io.Reader + if body != nil { + b, err := json.Marshal(body) + if err != nil { + h.t.Fatalf("marshal: %v", err) + } + r = bytes.NewReader(b) + } + req, err := http.NewRequest(method, h.srv.URL+path, r) + if err != nil { + h.t.Fatalf("new req: %v", err) + } + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + if token != "" { + req.Header.Set("Authorization", "Bearer "+token) + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + h.t.Fatalf("do %s %s: %v", method, path, err) + } + defer resp.Body.Close() + data, _ := io.ReadAll(resp.Body) + return resp.StatusCode, data +} + +func (h *harness) serverInfo() (int, map[string]any) { + code, body := h.do("GET", "/api/v1/server/info", "", nil) + var m map[string]any + _ = json.Unmarshal(body, &m) + return code, m +} + +func TestServerInfoNeedsSetup(t *testing.T) { + h := newHarness(t) + code, m := h.serverInfo() + if code != 200 { + t.Fatalf("status %d", code) + } + if m["needs_setup"] != true { + t.Fatalf("expected needs_setup=true, got %v", m["needs_setup"]) + } + if m["default_key_fingerprint"] == "" { + t.Fatal("expected fingerprint") + } +} + +func TestRegisterFirstUserIsAdmin(t *testing.T) { + h := newHarness(t) + code, body := h.do("POST", "/api/v1/auth/register", "", map[string]string{ + "username": "alice", "password": "supersecret", + }) + if code != 201 { + t.Fatalf("register status %d body %s", code, body) + } + var resp struct { + User map[string]any `json:"user"` + Token string `json:"token"` + } + if err := json.Unmarshal(body, &resp); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if resp.Token == "" { + t.Fatal("empty token") + } + if resp.User["is_admin"] != true { + t.Fatalf("first user should be admin, got %v", resp.User["is_admin"]) + } + h.token = resp.Token + + // /me with token + code, body = h.do("GET", "/api/v1/me", h.token, nil) + if code != 200 { + t.Fatalf("me status %d", code) + } + + // needs_setup should now be false + _, m := h.serverInfo() + if m["needs_setup"] != false { + t.Fatalf("expected needs_setup=false after register") + } +} + +func TestRegisterRejectsBadUsername(t *testing.T) { + h := newHarness(t) + code, _ := h.do("POST", "/api/v1/auth/register", "", map[string]string{ + "username": "A", "password": "supersecret", + }) + if code != 400 { + t.Fatalf("expected 400 for short username, got %d", code) + } +} + +func TestLoginAndAuthFlow(t *testing.T) { + h := newHarness(t) + h.do("POST", "/api/v1/auth/register", "", map[string]string{ + "username": "bob", "password": "supersecret", + }) + + code, body := h.do("POST", "/api/v1/auth/login", "", map[string]string{ + "username": "bob", "password": "supersecret", + }) + if code != 200 { + t.Fatalf("login status %d", code) + } + var resp struct { + Token string `json:"token"` + } + json.Unmarshal(body, &resp) + if resp.Token == "" { + t.Fatal("empty token") + } + + // wrong password + code, _ = h.do("POST", "/api/v1/auth/login", "", map[string]string{ + "username": "bob", "password": "wrongpassword", + }) + if code != 401 { + t.Fatalf("expected 401 for wrong password, got %d", code) + } + + // me without token + code, _ = h.do("GET", "/api/v1/me", "", nil) + if code != 401 { + t.Fatalf("expected 401 without token, got %d", code) + } + + // tokens list + code, _ = h.do("GET", "/api/v1/me/tokens", resp.Token, nil) + if code != 200 { + t.Fatalf("tokens list status %d", code) + } + + // create token + code, body = h.do("POST", "/api/v1/me/tokens", resp.Token, map[string]string{"name": "laptop"}) + if code != 201 { + t.Fatalf("create token status %d", code) + } + + // logout + code, _ = h.do("POST", "/api/v1/auth/logout", resp.Token, nil) + if code != 204 { + t.Fatalf("logout status %d", code) + } + // token now revoked + code, _ = h.do("GET", "/api/v1/me", resp.Token, nil) + if code != 401 { + t.Fatalf("expected 401 after logout, got %d", code) + } +} + +func TestUsersAdminOnly(t *testing.T) { + h := newHarness(t) + // register two users; first is admin + _, body := h.do("POST", "/api/v1/auth/register", "", map[string]string{"username": "admin", "password": "supersecret"}) + var admin struct { + Token string `json:"token"` + } + json.Unmarshal(body, &admin) + + _, body = h.do("POST", "/api/v1/auth/register", "", map[string]string{"username": "carol", "password": "supersecret"}) + var carol struct { + User map[string]any `json:"user"` + Token string `json:"token"` + } + json.Unmarshal(body, &carol) + if carol.User["is_admin"] == true { + t.Fatal("second user should not be admin") + } + + // carol cannot list users + code, _ := h.do("GET", "/api/v1/users", carol.Token, nil) + if code != 403 { + t.Fatalf("non-admin list users should be 403, got %d", code) + } + // admin can list users + code, body = h.do("GET", "/api/v1/users", admin.Token, nil) + if code != 200 { + t.Fatalf("admin list users should be 200, got %d", code) + } + + // admin cannot delete self + code, _ = h.do("DELETE", "/api/v1/users/"+carol.User["id"].(string), admin.Token, nil) + if code != 204 { + t.Fatalf("admin delete carol should be 204, got %d", code) + } +} + +// keep context import used in case of future expansion +var _ = context.Background + +// testSigner implements restapi.SignerProvider for tests. +type testSigner struct{ key *gpg.Key } + +func (s *testSigner) PublicKeyArmored() (string, error) { return s.key.ArmoredPublic() } +func (s *testSigner) Fingerprint() string { return s.key.Fingerprint } diff --git a/server/restapi/auth.go b/server/restapi/auth.go new file mode 100644 index 0000000..d240f4d --- /dev/null +++ b/server/restapi/auth.go @@ -0,0 +1,195 @@ +package restapi + +import ( + "context" + "errors" + "net/http" + "strings" + + "urapt/server/middleware" + "urapt/server/store" + apitypes "urapt/shared/api" + "urapt/shared/crypto" + "urapt/shared/httputil" + "urapt/shared/models" +) + +// Register creates a new account. The first account becomes the admin. When +// open_registration is false and an account already exists, registration is +// closed to non-admins. +func (api *API) Register(w http.ResponseWriter, r *http.Request) { + var req apitypes.RegisterRequest + if err := httputil.ReadJSON(r, &req, 1<<20); err != nil { + bad(w, "invalid JSON body") + return + } + req.Username = strings.TrimSpace(req.Username) + if !validateUsername(req.Username) { + bad(w, "username must be 3-32 chars of [a-z0-9_-]") + return + } + if !validatePassword(req.Password) { + bad(w, "password must be 8-256 chars") + return + } + + users, err := api.Store.ListUsers(r.Context()) + if err != nil { + httputil.WriteError(w, http.StatusInternalServerError, httputil.CodeInternal, "failed to read users") + return + } + if len(users) > 0 && !api.Config.OpenRegistration { + httputil.WriteError(w, http.StatusForbidden, httputil.CodeForbidden, "registration is closed") + return + } + + if _, err := api.Store.GetUserByUsername(r.Context(), req.Username); err == nil { + httputil.WriteError(w, http.StatusConflict, httputil.CodeConflict, "username already taken") + return + } else if !errors.Is(err, store.ErrNotFound) { + httputil.WriteError(w, http.StatusInternalServerError, httputil.CodeInternal, "failed to check username") + return + } + + hash, err := crypto.HashPassword(req.Password) + if err != nil { + httputil.WriteError(w, http.StatusInternalServerError, httputil.CodeInternal, "failed to hash password") + return + } + user, _, err := api.Store.CreateUser(r.Context(), req.Username, hash) + if err != nil { + httputil.WriteError(w, http.StatusInternalServerError, httputil.CodeInternal, "failed to create user") + return + } + + token, err := api.issueToken(r.Context(), user.ID, "login") + if err != nil { + httputil.WriteError(w, http.StatusInternalServerError, httputil.CodeInternal, "failed to issue token") + return + } + _ = api.Store.RecordAudit(r.Context(), &user.ID, nil, "user.register", user.Username, "") + httputil.WriteJSON(w, http.StatusCreated, apitypes.AuthResponse{User: user, Token: token}) +} + +// Login authenticates a user and issues a new API token. +func (api *API) Login(w http.ResponseWriter, r *http.Request) { + var req apitypes.LoginRequest + if err := httputil.ReadJSON(r, &req, 1<<20); err != nil { + bad(w, "invalid JSON body") + return + } + user, err := api.Store.GetUserByUsername(r.Context(), req.Username) + if err != nil { + httputil.WriteError(w, http.StatusUnauthorized, httputil.CodeUnauthorized, "invalid credentials") + return + } + // Need the password hash; fetch via a dedicated method. + hash, err := api.Store.GetUserPasswordHash(r.Context(), user.ID) + if err != nil { + httputil.WriteError(w, http.StatusInternalServerError, httputil.CodeInternal, "failed to read user") + return + } + if !crypto.VerifyPassword(hash, req.Password) { + httputil.WriteError(w, http.StatusUnauthorized, httputil.CodeUnauthorized, "invalid credentials") + return + } + token, err := api.issueToken(r.Context(), user.ID, "login") + if err != nil { + httputil.WriteError(w, http.StatusInternalServerError, httputil.CodeInternal, "failed to issue token") + return + } + _ = api.Store.RecordAudit(r.Context(), &user.ID, nil, "user.login", user.Username, "") + httputil.WriteJSON(w, http.StatusOK, apitypes.AuthResponse{User: user, Token: token}) +} + +// Logout revokes the caller's current token. +func (api *API) Logout(w http.ResponseWriter, r *http.Request) { + id := middleware.IdentityFromContext(r.Context()) + if err := api.Store.RevokeToken(r.Context(), id.User.ID, id.TokenID); err != nil { + httputil.WriteError(w, http.StatusInternalServerError, httputil.CodeInternal, "failed to revoke token") + return + } + w.WriteHeader(http.StatusNoContent) +} + +// Me returns the caller's user record. +func (api *API) Me(w http.ResponseWriter, r *http.Request) { + id := middleware.IdentityFromContext(r.Context()) + httputil.WriteJSON(w, http.StatusOK, id.User) +} + +// ListTokens returns the caller's tokens. +func (api *API) ListTokens(w http.ResponseWriter, r *http.Request) { + id := middleware.IdentityFromContext(r.Context()) + tokens, err := api.Store.ListTokens(r.Context(), id.User.ID) + if err != nil { + httputil.WriteError(w, http.StatusInternalServerError, httputil.CodeInternal, "failed to list tokens") + return + } + if tokens == nil { + tokens = []*models.APIToken{} + } + httputil.WriteJSON(w, http.StatusOK, apitypes.ListResponse[*models.APIToken]{Items: tokens, Page: 1, PerPage: 100, Total: len(tokens)}) +} + +// CreateToken issues a new named token for the caller. +func (api *API) CreateToken(w http.ResponseWriter, r *http.Request) { + var req apitypes.CreateTokenRequest + if err := httputil.ReadJSON(r, &req, 1<<20); err != nil { + bad(w, "invalid JSON body") + return + } + if !tokenNameRE.MatchString(req.Name) { + bad(w, "name must be 1-64 chars") + return + } + id := middleware.IdentityFromContext(r.Context()) + token, row, err := api.issueTokenRow(r.Context(), id.User.ID, req.Name) + if err != nil { + httputil.WriteError(w, http.StatusInternalServerError, httputil.CodeInternal, "failed to issue token") + return + } + row.Token = token + _ = api.Store.RecordAudit(r.Context(), &id.User.ID, nil, "token.create", req.Name, "") + httputil.WriteJSON(w, http.StatusCreated, row) +} + +// RevokeToken revokes one of the caller's tokens by id. +func (api *API) RevokeToken(w http.ResponseWriter, r *http.Request) { + id := middleware.IdentityFromContext(r.Context()) + tokenID := r.PathValue("id") + if err := api.Store.RevokeToken(r.Context(), id.User.ID, tokenID); err != nil { + if errors.Is(err, store.ErrNotFound) { + httputil.WriteError(w, http.StatusNotFound, httputil.CodeNotFound, "token not found") + return + } + httputil.WriteError(w, http.StatusInternalServerError, httputil.CodeInternal, "failed to revoke token") + return + } + w.WriteHeader(http.StatusNoContent) +} + +// issueToken generates a token, persists its hash, and returns the plaintext. +func (api *API) issueToken(ctx context.Context, userID, name string) (string, error) { + token, hash, prefix, err := crypto.GenerateToken() + if err != nil { + return "", err + } + if _, err := api.Store.CreateToken(ctx, userID, name, prefix, hash); err != nil { + return "", err + } + return token, nil +} + +// issueTokenRow is like issueToken but also returns the persisted token row. +func (api *API) issueTokenRow(ctx context.Context, userID, name string) (string, *models.APIToken, error) { + token, hash, prefix, err := crypto.GenerateToken() + if err != nil { + return "", nil, err + } + row, err := api.Store.CreateToken(ctx, userID, name, prefix, hash) + if err != nil { + return "", nil, err + } + return token, row, nil +} diff --git a/server/restapi/packages.go b/server/restapi/packages.go new file mode 100644 index 0000000..60d61af --- /dev/null +++ b/server/restapi/packages.go @@ -0,0 +1,339 @@ +package restapi + +import ( + "errors" + "io" + "net/http" + "os" + "path/filepath" + "strconv" + "strings" + + "urapt/server/middleware" + "urapt/server/store" + "urapt/shared/apt" + "urapt/shared/deb" + "urapt/shared/httputil" + "urapt/shared/models" +) + +// ListPackages lists packages in a (repo, distribution) with optional filters. +func (api *API) ListPackages(w http.ResponseWriter, r *http.Request) { + repo, dist := api.loadDistro(w, r) + if dist == nil { + return + } + f := store.PackageFilters{ + ComponentID: r.URL.Query().Get("component"), + Arch: r.URL.Query().Get("arch"), + Name: r.URL.Query().Get("name"), + Query: r.URL.Query().Get("q"), + } + page, _ := strconv.Atoi(r.URL.Query().Get("page")) + perPage, _ := strconv.Atoi(r.URL.Query().Get("per_page")) + pkgs, total, err := api.Store.ListPackages(r.Context(), repo.ID, dist.ID, f, page, perPage) + if err != nil { + httputil.WriteError(w, http.StatusInternalServerError, httputil.CodeInternal, "failed to list packages") + return + } + if pkgs == nil { + pkgs = []*models.Package{} + } + httputil.WriteJSON(w, http.StatusOK, map[string]any{ + "items": pkgs, "page": pageOr(page), "per_page": perPageOr(perPage), "total": total, + }) +} + +// PushPackage receives a multipart .deb upload, validates it, stores the blob, +// and records the package. +func (api *API) PushPackage(w http.ResponseWriter, r *http.Request) { + repo, dist := api.requireDistroWrite(w, r) + if dist == nil { + return + } + id := middleware.IdentityFromContext(r.Context()) + + // Stream the multipart upload to a temp file in the packages dir. + tempPath, origName, componentName, err := api.receiveUpload(r) + if err != nil { + httputil.WriteError(w, http.StatusBadRequest, httputil.CodeBadRequest, err.Error()) + return + } + cleanup := func() { _ = os.Remove(tempPath) } + defer func() { _ = os.Remove(tempPath) }() + + if componentName == "" { + bad(w, "component field is required") + return + } + component, err := api.Store.GetComponentByName(r.Context(), dist.ID, componentName) + if err != nil { + httputil.WriteError(w, http.StatusBadRequest, httputil.CodeBadRequest, "component not found in distribution") + return + } + + // Parse and hash the uploaded .deb. + inspected, err := deb.Inspect(tempPath) + if err != nil { + httputil.WriteError(w, http.StatusBadRequest, httputil.CodeBadRequest, "invalid .deb: "+err.Error()) + return + } + ctrl := inspected.Control + if ctrl.Get("Package") == "" || ctrl.Get("Version") == "" || ctrl.Get("Architecture") == "" { + httputil.WriteError(w, http.StatusBadRequest, httputil.CodeBadRequest, "control missing Package/Version/Architecture") + return + } + + // Validate architecture is configured (or "all"). + pkgArch := ctrl.Get("Architecture") + if pkgArch != "all" { + ok, err := api.Store.HasArchitecture(r.Context(), dist.ID, pkgArch) + if err != nil { + httputil.WriteError(w, http.StatusInternalServerError, httputil.CodeInternal, "failed to check architecture") + return + } + if !ok { + httputil.WriteError(w, http.StatusBadRequest, httputil.CodeBadRequest, "architecture "+pkgArch+" not configured for distribution") + return + } + } + + // Dedup on (repo, distro, component, name, version, arch). + existing, err := api.Store.GetPackageByPoolPath(r.Context(), repo.ID, + apt.PoolPath(component.Name, ctrl.Get("Source"), ctrl.Get("Package"), origName)) + _ = existing + if err == nil { + httputil.WriteError(w, http.StatusConflict, httputil.CodeConflict, + "package "+ctrl.Get("Package")+"_"+ctrl.Get("Version")+"_"+pkgArch+" already exists") + return + } else if !errors.Is(err, store.ErrNotFound) { + httputil.WriteError(w, http.StatusInternalServerError, httputil.CodeInternal, "failed to check duplicate") + return + } + + // Find-or-create the content-addressed blob. + blobFileName := api.blobFilePath(inspected.SHA256) + created, err := api.Store.CreateBlob(r.Context(), inspected.SHA256, blobFileName, inspected.Size) + if err != nil { + httputil.WriteError(w, http.StatusInternalServerError, httputil.CodeInternal, "failed to record blob") + return + } + if created { + // New blob: move the temp file into place. + if err := os.Rename(tempPath, blobFileName); err != nil { + _ = api.Store.DeleteBlob(r.Context(), inspected.SHA256) + httputil.WriteError(w, http.StatusInternalServerError, httputil.CodeInternal, "failed to store package file") + return + } + } else { + // Existing blob: increment ref count and discard the temp upload. + if _, err := api.Store.IncBlobRef(r.Context(), inspected.SHA256); err != nil { + httputil.WriteError(w, http.StatusInternalServerError, httputil.CodeInternal, "failed to increment blob ref") + return + } + cleanup() + } + + pool := apt.PoolPath(component.Name, ctrl.Get("Source"), ctrl.Get("Package"), origName) + pkg := &models.Package{ + RepositoryID: repo.ID, + DistributionID: dist.ID, + ComponentID: component.ID, + Name: ctrl.Get("Package"), + Version: ctrl.Get("Version"), + Architecture: pkgArch, + Source: ctrl.Get("Source"), + Maintainer: ctrl.Get("Maintainer"), + Priority: ctrl.Get("Priority"), + Section: ctrl.Get("Section"), + Origin: ctrl.Get("Origin"), + Homepage: ctrl.Get("Homepage"), + Description: ctrl.Get("Description"), + DescriptionMD5: ctrl.DescriptionMD5(), + Depends: ctrl.Get("Depends"), + PreDepends: ctrl.Get("Pre-Depends"), + Recommends: ctrl.Get("Recommends"), + Suggests: ctrl.Get("Suggests"), + Conflicts: ctrl.Get("Conflicts"), + Breaks: ctrl.Get("Breaks"), + Provides: ctrl.Get("Provides"), + Replaces: ctrl.Get("Replaces"), + Enhances: ctrl.Get("Enhances"), + InstalledSize: parseInt64(ctrl.Get("Installed-Size")), + Essential: ctrl.Get("Essential"), + BuiltUsing: ctrl.Get("Built-Using"), + Tag: ctrl.Get("Tag"), + RawControl: ctrl.Raw, + Filename: blobFileName, + PoolPath: pool, + Size: inspected.Size, + MD5sum: inspected.MD5sum, + SHA1: inspected.SHA1, + SHA256: inspected.SHA256, + UploadedByUserID: id.User.ID, + } + if err := api.Store.CreatePackage(r.Context(), pkg); err != nil { + // Roll back the blob ref we added. + if rc, _ := api.Store.DecBlobRef(r.Context(), inspected.SHA256); rc == 0 { + _ = api.Store.DeleteBlob(r.Context(), inspected.SHA256) + _ = os.Remove(blobFileName) + } + httputil.WriteError(w, http.StatusConflict, httputil.CodeConflict, "package already exists or invalid") + return + } + api.Cache.Invalidate(repo.ID, dist.Name) + _ = api.Store.RecordAudit(r.Context(), &id.User.ID, &repo.ID, "package.push", pkg.Name+"_"+pkg.Version, pkg.Architecture) + httputil.WriteJSON(w, http.StatusCreated, pkg) +} + +// GetPackage returns a single package by id. +func (api *API) GetPackage(w http.ResponseWriter, r *http.Request) { + repo := api.requireRead(w, r) + if repo == nil { + return + } + pkg, err := api.Store.GetPackageByID(r.Context(), r.PathValue("id")) + if err != nil { + if errors.Is(err, store.ErrNotFound) { + httputil.WriteError(w, http.StatusNotFound, httputil.CodeNotFound, "package not found") + return + } + httputil.WriteError(w, http.StatusInternalServerError, httputil.CodeInternal, "failed to read package") + return + } + if pkg.RepositoryID != repo.ID { + httputil.WriteError(w, http.StatusNotFound, httputil.CodeNotFound, "package not found") + return + } + httputil.WriteJSON(w, http.StatusOK, pkg) +} + +// GetPackageFile streams a package's .deb file (used by the CLI pull command). +func (api *API) GetPackageFile(w http.ResponseWriter, r *http.Request) { + repo := api.requireRead(w, r) + if repo == nil { + return + } + pkg, err := api.Store.GetPackageByID(r.Context(), r.PathValue("id")) + if err != nil || pkg.RepositoryID != repo.ID { + httputil.WriteError(w, http.StatusNotFound, httputil.CodeNotFound, "package not found") + return + } + path := api.blobFilePath(pkg.SHA256) + w.Header().Set("Content-Disposition", `attachment; filename="`+filepath.Base(pkg.PoolPath)+`"`) + http.ServeFile(w, r, path) +} + +// DeletePackage removes a package and decrements its blob reference. +func (api *API) DeletePackage(w http.ResponseWriter, r *http.Request) { + repo := api.requireWrite(w, r) + if repo == nil { + return + } + pkg, err := api.Store.GetPackageByID(r.Context(), r.PathValue("id")) + if err != nil || pkg.RepositoryID != repo.ID { + httputil.WriteError(w, http.StatusNotFound, httputil.CodeNotFound, "package not found") + return + } + if err := api.Store.DeletePackage(r.Context(), pkg.ID); err != nil { + httputil.WriteError(w, http.StatusInternalServerError, httputil.CodeInternal, "failed to delete package") + return + } + if rc, _ := api.Store.DecBlobRef(r.Context(), pkg.SHA256); rc == 0 { + _ = api.Store.DeleteBlob(r.Context(), pkg.SHA256) + _ = os.Remove(api.blobFilePath(pkg.SHA256)) + } + api.Cache.InvalidateRepo(repo.ID) + id := middleware.IdentityFromContext(r.Context()) + _ = api.Store.RecordAudit(r.Context(), &id.User.ID, &repo.ID, "package.delete", pkg.Name+"_"+pkg.Version, pkg.Architecture) + w.WriteHeader(http.StatusNoContent) +} + +// receiveUpload streams a multipart upload (field "file" + "component") to a +// temp file in the packages directory, enforcing the size limit. Returns the +// temp path, original filename, component name, and any error. +func (api *API) receiveUpload(r *http.Request) (tempPath, origName, component string, err error) { + reader, err := r.MultipartReader() + if err != nil { + return "", "", "", errors.New("expected multipart/form-data") + } + maxSize := api.Config.MaxPackageSize + + f, err := os.CreateTemp(api.Config.PackagesDir, ".upload-*") + if err != nil { + return "", "", "", errors.New("failed to create temp file") + } + tempPath = f.Name() + defer func() { _ = f.Close() }() + + gotFile := false + gotComponent := false + var written int64 + for { + part, perr := reader.NextPart() + if perr == io.EOF { + break + } + if perr != nil { + return tempPath, "", "", perr + } + switch part.FormName() { + case "component": + data, derr := io.ReadAll(io.LimitReader(part, 256)) + if derr != nil { + return tempPath, "", "", derr + } + component = strings.TrimSpace(string(data)) + gotComponent = true + case "file": + origName = part.FileName() + if origName == "" { + return tempPath, "", "", errors.New("file field has no filename") + } + n, werr := io.Copy(f, io.LimitReader(part, maxSize+1)) + if werr != nil { + return tempPath, origName, "", werr + } + written = n + gotFile = true + default: + // ignore unknown fields + } + } + if !gotFile { + return tempPath, "", "", errors.New("missing 'file' field") + } + if !gotComponent { + return tempPath, origName, "", errors.New("missing 'component' field") + } + if written > maxSize { + return tempPath, origName, "", errors.New("package exceeds max size") + } + _ = gotComponent + return tempPath, origName, component, nil +} + +// parseInt64 parses a base-10 int64, returning 0 on error. +func parseInt64(s string) int64 { + n, _ := strconv.ParseInt(s, 10, 64) + return n +} + +// pageOr defaults page to 1. +func pageOr(p int) int { + if p < 1 { + return 1 + } + return p +} + +// perPageOr defaults per-page to 25. +func perPageOr(p int) int { + if p < 1 { + return 25 + } + if p > 100 { + return 100 + } + return p +} diff --git a/server/restapi/packages_test.go b/server/restapi/packages_test.go new file mode 100644 index 0000000..a94e1c0 --- /dev/null +++ b/server/restapi/packages_test.go @@ -0,0 +1,192 @@ +package restapi + +import ( + "archive/tar" + "bytes" + "compress/gzip" + "encoding/json" + "io" + "mime/multipart" + "net/http" + "os" + "path/filepath" + "testing" +) + +// buildDebBytes constructs a minimal valid .deb with the given control text. +func buildDebBytes(t *testing.T, controlText string) []byte { + t.Helper() + var ctrlBuf bytes.Buffer + gz := gzip.NewWriter(&ctrlBuf) + tw := tar.NewWriter(gz) + writeTarFile(t, tw, "control", controlText) + tw.Close() + gz.Close() + + var dataBuf bytes.Buffer + gz2 := gzip.NewWriter(&dataBuf) + tw2 := tar.NewWriter(gz2) + writeTarFile(t, tw2, "usr/share/foo", "x") + tw2.Close() + gz2.Close() + + var out bytes.Buffer + out.WriteString("!\n") + writeArMemberBytes(&out, "debian-binary", []byte("2.0\n")) + writeArMemberBytes(&out, "control.tar.gz", ctrlBuf.Bytes()) + writeArMemberBytes(&out, "data.tar.gz", dataBuf.Bytes()) + return out.Bytes() +} + +func writeTarFile(t *testing.T, tw *tar.Writer, name, body string) { + t.Helper() + if err := tw.WriteHeader(&tar.Header{Name: name, Mode: 0o644, Size: int64(len(body)), Typeflag: tar.TypeReg}); err != nil { + t.Fatalf("tar header: %v", err) + } + if _, err := tw.Write([]byte(body)); err != nil { + t.Fatalf("tar write: %v", err) + } +} + +func writeArMemberBytes(buf *bytes.Buffer, name string, data []byte) { + header := make([]byte, 60) + for i := range header { + header[i] = ' ' + } + copy(header[0:], name+"/") + ds := []byte(padLeftInt(len(data), 10)) + copy(header[48:], ds) + header[58] = '`' + header[59] = '\n' + buf.Write(header) + buf.Write(data) + if len(data)%2 == 1 { + buf.WriteByte('\n') + } +} + +func padLeftInt(n, width int) string { + s := make([]byte, width) + for i := range s { + s[i] = ' ' + } + digits := []byte(itoaInt(n)) + copy(s[len(s)-len(digits):], digits) + return string(s) +} + +func itoaInt(n int) string { + if n == 0 { + return "0" + } + var b []byte + for n > 0 { + b = append([]byte{byte('0' + n%10)}, b...) + n /= 10 + } + return string(b) +} + +// uploadPackage POSTs a multipart push. +func (h *harness) uploadPackage(token, repo, dist, component string, deb []byte) (int, []byte) { + h.t.Helper() + var buf bytes.Buffer + mw := multipart.NewWriter(&buf) + _ = mw.WriteField("component", component) + fw, _ := mw.CreateFormFile("file", "foo_1.0_amd64.deb") + fw.Write(deb) + mw.Close() + + req, _ := http.NewRequest("POST", h.srv.URL+"/api/v1/repositories/"+repo+"/distributions/"+dist+"/packages", &buf) + req.Header.Set("Content-Type", mw.FormDataContentType()) + req.Header.Set("Authorization", "Bearer "+token) + resp, err := http.DefaultClient.Do(req) + if err != nil { + h.t.Fatalf("upload: %v", err) + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + return resp.StatusCode, body +} + +func TestPushPullDeletePackage(t *testing.T) { + h := newHarness(t) + _, body := h.do("POST", "/api/v1/auth/register", "", map[string]string{"username": "owner", "password": "supersecret"}) + var owner struct { + Token string `json:"token"` + } + json.Unmarshal(body, &owner) + + // Set packages dir so blobs land in the temp dir. + _ = h.store // packages dir is taken from config (temp dir in harness). + + h.do("POST", "/api/v1/repositories", owner.Token, map[string]any{"name": "pkgrepo", "visibility": "public"}) + h.do("POST", "/api/v1/repositories/pkgrepo/distributions", owner.Token, map[string]string{"name": "stable"}) + h.do("POST", "/api/v1/repositories/pkgrepo/distributions/stable/components", owner.Token, map[string]string{"name": "main"}) + h.do("POST", "/api/v1/repositories/pkgrepo/distributions/stable/architectures", owner.Token, map[string]string{"name": "amd64"}) + + debBytes := buildDebBytes(t, "Package: foo\nVersion: 1.0\nArchitecture: amd64\nMaintainer: Test \nDescription: short\n extended\n") + + code, body := h.uploadPackage(owner.Token, "pkgrepo", "stable", "main", debBytes) + if code != 201 { + t.Fatalf("push status %d body %s", code, body) + } + var pkg struct { + ID string `json:"id"` + Name string `json:"name"` + Pool string `json:"pool_path"` + SHA string `json:"sha256"` + } + json.Unmarshal(body, &pkg) + if pkg.Name != "foo" || pkg.SHA == "" { + t.Fatalf("unexpected pkg: %+v", pkg) + } + + // duplicate push should 409 + code, _ = h.uploadPackage(owner.Token, "pkgrepo", "stable", "main", debBytes) + if code != 409 { + t.Fatalf("duplicate push should be 409, got %d", code) + } + + // list + code, body = h.do("GET", "/api/v1/repositories/pkgrepo/distributions/stable/packages", owner.Token, nil) + if code != 200 { + t.Fatalf("list packages status %d", code) + } + + // get + code, _ = h.do("GET", "/api/v1/repositories/pkgrepo/packages/"+pkg.ID, owner.Token, nil) + if code != 200 { + t.Fatalf("get package status %d", code) + } + + // download file + code, body = h.do("GET", "/api/v1/repositories/pkgrepo/packages/"+pkg.ID+"/file", owner.Token, nil) + if code != 200 { + t.Fatalf("get file status %d", code) + } + if !bytes.Equal(body, debBytes) { + t.Fatalf("downloaded file does not match uploaded (%d vs %d bytes)", len(body), len(debBytes)) + } + + // verify blob file exists on disk in the temp packages dir + blobPath := filepath.Join(h.pkgDir, pkg.SHA+".deb") + if _, err := os.Stat(blobPath); err != nil { + t.Fatalf("blob file missing on disk: %v", err) + } + + // delete + code, _ = h.do("DELETE", "/api/v1/repositories/pkgrepo/packages/"+pkg.ID, owner.Token, nil) + if code != 204 { + t.Fatalf("delete package status %d", code) + } + // get now 404 + code, _ = h.do("GET", "/api/v1/repositories/pkgrepo/packages/"+pkg.ID, owner.Token, nil) + if code != 404 { + t.Fatalf("deleted package should 404, got %d", code) + } + // blob file removed after refcount hits 0 + if _, err := os.Stat(blobPath); !os.IsNotExist(err) { + t.Fatalf("blob file should be removed after delete, err=%v", err) + } +} diff --git a/server/restapi/repos.go b/server/restapi/repos.go new file mode 100644 index 0000000..516516d --- /dev/null +++ b/server/restapi/repos.go @@ -0,0 +1,368 @@ +package restapi + +import ( + "errors" + "net/http" + "os" + "path/filepath" + "regexp" + "strings" + + "urapt/server/middleware" + "urapt/server/store" + apitypes "urapt/shared/api" + "urapt/shared/httputil" + "urapt/shared/models" +) + +// nameRE is the shared validator for repository, distribution, component, and +// architecture names: lowercase, starting alphanumeric, allowing -+., length +// 1-64. +var nameRE = regexp.MustCompile(`^[a-z0-9][a-z0-9+.\-]{0,63}$`) + +// loadRepoByName fetches a repository by its {repo} path param, rendering the +// appropriate error. nil is returned only after an error has been written. +func (api *API) loadRepoByName(w http.ResponseWriter, r *http.Request) *models.Repository { + name := r.PathValue("repo") + repo, err := api.Store.GetRepositoryByName(r.Context(), name) + if err != nil { + if errors.Is(err, store.ErrNotFound) { + httputil.WriteError(w, http.StatusNotFound, httputil.CodeNotFound, "repository not found") + return nil + } + httputil.WriteError(w, http.StatusInternalServerError, httputil.CodeInternal, "failed to read repository") + return nil + } + return repo +} + +// requireRead loads the repo and checks CanRead; returns the repo or nil (after +// writing an error). +func (api *API) requireRead(w http.ResponseWriter, r *http.Request) *models.Repository { + repo := api.loadRepoByName(w, r) + if repo == nil { + return nil + } + id := middleware.IdentityFromContext(r.Context()) + ok, err := api.Auth.CanRead(r.Context(), id.User, repo) + if err != nil { + httputil.WriteError(w, http.StatusInternalServerError, httputil.CodeInternal, "permission check failed") + return nil + } + if !ok { + httputil.WriteError(w, http.StatusForbidden, httputil.CodeForbidden, "no read access") + return nil + } + return repo +} + +// requireWrite loads the repo and checks CanWrite. +func (api *API) requireWrite(w http.ResponseWriter, r *http.Request) *models.Repository { + repo := api.loadRepoByName(w, r) + if repo == nil { + return nil + } + id := middleware.IdentityFromContext(r.Context()) + ok, err := api.Auth.CanWrite(r.Context(), id.User, repo) + if err != nil { + httputil.WriteError(w, http.StatusInternalServerError, httputil.CodeInternal, "permission check failed") + return nil + } + if !ok { + httputil.WriteError(w, http.StatusForbidden, httputil.CodeForbidden, "no write access") + return nil + } + return repo +} + +// requireManage loads the repo and checks CanManage. +func (api *API) requireManage(w http.ResponseWriter, r *http.Request) *models.Repository { + repo := api.loadRepoByName(w, r) + if repo == nil { + return nil + } + id := middleware.IdentityFromContext(r.Context()) + ok, err := api.Auth.CanManage(r.Context(), id.User, repo) + if err != nil { + httputil.WriteError(w, http.StatusInternalServerError, httputil.CodeInternal, "permission check failed") + return nil + } + if !ok { + httputil.WriteError(w, http.StatusForbidden, httputil.CodeForbidden, "manage access required") + return nil + } + return repo +} + +// ListRepositories returns repositories visible to the caller. +func (api *API) ListRepositories(w http.ResponseWriter, r *http.Request) { + id := middleware.IdentityFromContext(r.Context()) + repos, err := api.Store.ListReposVisible(r.Context(), id.User.ID) + if err != nil { + httputil.WriteError(w, http.StatusInternalServerError, httputil.CodeInternal, "failed to list repositories") + return + } + if repos == nil { + repos = []*models.Repository{} + } + httputil.WriteJSON(w, http.StatusOK, apitypes.ListResponse[*models.Repository]{Items: repos, Page: 1, PerPage: 100, Total: len(repos)}) +} + +// CreateRepository creates a new repository owned by the caller. +func (api *API) CreateRepository(w http.ResponseWriter, r *http.Request) { + var req apitypes.CreateRepoRequest + if err := httputil.ReadJSON(r, &req, 1<<20); err != nil { + bad(w, "invalid JSON body") + return + } + req.Name = strings.TrimSpace(req.Name) + if !nameRE.MatchString(req.Name) { + bad(w, "name must be 1-64 chars of [a-z0-9][a-z0-9+.-]") + return + } + vis := models.Visibility(strings.ToLower(req.Visibility)) + if vis != models.VisibilityPublic && vis != models.VisibilityPrivate { + bad(w, "visibility must be 'public' or 'private'") + return + } + id := middleware.IdentityFromContext(r.Context()) + if _, err := api.Store.GetRepositoryByName(r.Context(), req.Name); err == nil { + httputil.WriteError(w, http.StatusConflict, httputil.CodeConflict, "repository name already taken") + return + } else if !errors.Is(err, store.ErrNotFound) { + httputil.WriteError(w, http.StatusInternalServerError, httputil.CodeInternal, "failed to check name") + return + } + repo, err := api.Store.CreateRepository(r.Context(), req.Name, id.User.ID, vis, req.Description) + if err != nil { + httputil.WriteError(w, http.StatusInternalServerError, httputil.CodeInternal, "failed to create repository") + return + } + _ = api.Store.RecordAudit(r.Context(), &id.User.ID, &repo.ID, "repo.create", repo.Name, "") + httputil.WriteJSON(w, http.StatusCreated, repo) +} + +// GetRepository returns a single repository. +func (api *API) GetRepository(w http.ResponseWriter, r *http.Request) { + repo := api.requireRead(w, r) + if repo == nil { + return + } + httputil.WriteJSON(w, http.StatusOK, repo) +} + +// UpdateRepository mutates a repository. +func (api *API) UpdateRepository(w http.ResponseWriter, r *http.Request) { + repo := api.requireManage(w, r) + if repo == nil { + return + } + var req apitypes.UpdateRepoRequest + if err := httputil.ReadJSON(r, &req, 1<<20); err != nil { + bad(w, "invalid JSON body") + return + } + var vis *models.Visibility + if req.Visibility != nil { + v := models.Visibility(strings.ToLower(*req.Visibility)) + if v != models.VisibilityPublic && v != models.VisibilityPrivate { + bad(w, "visibility must be 'public' or 'private'") + return + } + vis = &v + } + if req.Name != nil { + if !nameRE.MatchString(*req.Name) { + bad(w, "name must be 1-64 chars of [a-z0-9][a-z0-9+.-]") + return + } + } + if err := api.Store.UpdateRepository(r.Context(), repo.ID, valStr(req.Name), vis, req.Description); err != nil { + httputil.WriteError(w, http.StatusInternalServerError, httputil.CodeInternal, "failed to update repository") + return + } + api.Cache.InvalidateRepo(repo.ID) + updated, _ := api.Store.GetRepositoryByID(r.Context(), repo.ID) + httputil.WriteJSON(w, http.StatusOK, updated) +} + +// DeleteRepository removes a repository and cleans up its blobs. +func (api *API) DeleteRepository(w http.ResponseWriter, r *http.Request) { + repo := api.requireManage(w, r) + if repo == nil { + return + } + shas, err := api.Store.ListPackageBlobSHA256sByRepo(r.Context(), repo.ID) + if err != nil { + httputil.WriteError(w, http.StatusInternalServerError, httputil.CodeInternal, "failed to list packages") + return + } + if err := api.Store.DeletePackagesByRepo(r.Context(), repo.ID); err != nil { + httputil.WriteError(w, http.StatusInternalServerError, httputil.CodeInternal, "failed to delete packages") + return + } + if err := api.Store.DeleteRepository(r.Context(), repo.ID); err != nil { + httputil.WriteError(w, http.StatusInternalServerError, httputil.CodeInternal, "failed to delete repository") + return + } + for _, sha := range shas { + rc, _ := api.Store.DecBlobRef(r.Context(), sha) + if rc == 0 { + _ = api.Store.DeleteBlob(r.Context(), sha) + _ = os.Remove(api.blobFilePath(sha)) + } + } + api.Cache.InvalidateRepo(repo.ID) + id := middleware.IdentityFromContext(r.Context()) + _ = api.Store.RecordAudit(r.Context(), &id.User.ID, &repo.ID, "repo.delete", repo.Name, "") + w.WriteHeader(http.StatusNoContent) +} + +// RepoPubkey returns the server's armored public key (convenience endpoint). +func (api *API) RepoPubkey(w http.ResponseWriter, r *http.Request) { + if api.requireRead(w, r) == nil { + return + } + api.ServerPubkey(w, r) +} + +// ListMembers returns the members of a repository. +func (api *API) ListMembers(w http.ResponseWriter, r *http.Request) { + repo := api.requireRead(w, r) + if repo == nil { + return + } + members, err := api.Store.ListMembers(r.Context(), repo.ID) + if err != nil { + httputil.WriteError(w, http.StatusInternalServerError, httputil.CodeInternal, "failed to list members") + return + } + if members == nil { + members = []*models.RepositoryMember{} + } + httputil.WriteJSON(w, http.StatusOK, members) +} + +// AddMember grants a user access on a repository. +func (api *API) AddMember(w http.ResponseWriter, r *http.Request) { + repo := api.requireManage(w, r) + if repo == nil { + return + } + var req apitypes.AddMemberRequest + if err := httputil.ReadJSON(r, &req, 1<<20); err != nil { + bad(w, "invalid JSON body") + return + } + target, err := api.Store.GetUserByUsername(r.Context(), req.Username) + if err != nil { + if errors.Is(err, store.ErrNotFound) { + httputil.WriteError(w, http.StatusNotFound, httputil.CodeNotFound, "user not found") + return + } + httputil.WriteError(w, http.StatusInternalServerError, httputil.CodeInternal, "failed to read user") + return + } + if !models.ValidAccess(req.Access) { + bad(w, "access must be read, write, read-write, or admin") + return + } + if target.ID == repo.OwnerUserID { + bad(w, "cannot change owner's access") + return + } + if err := api.Store.AddMember(r.Context(), repo.ID, target.ID, models.Access(req.Access)); err != nil { + httputil.WriteError(w, http.StatusInternalServerError, httputil.CodeInternal, "failed to add member") + return + } + _ = api.Store.RecordAudit(r.Context(), &repo.OwnerUserID, &repo.ID, "member.add", req.Username, req.Access) + httputil.WriteJSON(w, http.StatusCreated, &models.RepositoryMember{ + RepositoryID: repo.ID, UserID: target.ID, Access: models.Access(req.Access), User: target, + }) +} + +// UpdateMember changes a member's access level. +func (api *API) UpdateMember(w http.ResponseWriter, r *http.Request) { + repo := api.requireManage(w, r) + if repo == nil { + return + } + var req apitypes.UpdateMemberRequest + if err := httputil.ReadJSON(r, &req, 1<<20); err != nil { + bad(w, "invalid JSON body") + return + } + if !models.ValidAccess(req.Access) { + bad(w, "access must be read, write, read-write, or admin") + return + } + username := r.PathValue("username") + target, err := api.Store.GetUserByUsername(r.Context(), username) + if err != nil { + if errors.Is(err, store.ErrNotFound) { + httputil.WriteError(w, http.StatusNotFound, httputil.CodeNotFound, "user not found") + return + } + httputil.WriteError(w, http.StatusInternalServerError, httputil.CodeInternal, "failed to read user") + return + } + if target.ID == repo.OwnerUserID { + bad(w, "cannot change owner's access") + return + } + if err := api.Store.UpdateMemberAccess(r.Context(), repo.ID, target.ID, models.Access(req.Access)); err != nil { + if errors.Is(err, store.ErrNotFound) { + httputil.WriteError(w, http.StatusNotFound, httputil.CodeNotFound, "member not found") + return + } + httputil.WriteError(w, http.StatusInternalServerError, httputil.CodeInternal, "failed to update member") + return + } + httputil.WriteJSON(w, http.StatusOK, &models.RepositoryMember{ + RepositoryID: repo.ID, UserID: target.ID, Access: models.Access(req.Access), User: target, + }) +} + +// RemoveMember revokes a user's access. +func (api *API) RemoveMember(w http.ResponseWriter, r *http.Request) { + repo := api.requireManage(w, r) + if repo == nil { + return + } + username := r.PathValue("username") + target, err := api.Store.GetUserByUsername(r.Context(), username) + if err != nil { + if errors.Is(err, store.ErrNotFound) { + httputil.WriteError(w, http.StatusNotFound, httputil.CodeNotFound, "user not found") + return + } + httputil.WriteError(w, http.StatusInternalServerError, httputil.CodeInternal, "failed to read user") + return + } + if target.ID == repo.OwnerUserID { + bad(w, "cannot remove owner") + return + } + if err := api.Store.RemoveMember(r.Context(), repo.ID, target.ID); err != nil { + if errors.Is(err, store.ErrNotFound) { + httputil.WriteError(w, http.StatusNotFound, httputil.CodeNotFound, "member not found") + return + } + httputil.WriteError(w, http.StatusInternalServerError, httputil.CodeInternal, "failed to remove member") + return + } + w.WriteHeader(http.StatusNoContent) +} + +// valStr returns s as a string pointer-or-nil. +func valStr(s *string) string { + if s == nil { + return "" + } + return *s +} + +// blobFilePath returns the on-disk path for a content-addressed blob. +func (api *API) blobFilePath(sha256 string) string { + return filepath.Join(api.Config.PackagesDir, sha256+".deb") +} diff --git a/server/restapi/repos_test.go b/server/restapi/repos_test.go new file mode 100644 index 0000000..6605734 --- /dev/null +++ b/server/restapi/repos_test.go @@ -0,0 +1,122 @@ +package restapi + +import ( + "encoding/json" + "testing" +) + +func TestRepoCreateAndPermissions(t *testing.T) { + h := newHarness(t) + // alice (owner/admin), bob (non-admin) + _, body := h.do("POST", "/api/v1/auth/register", "", map[string]string{"username": "alice", "password": "supersecret"}) + var alice struct { + User map[string]any `json:"user"` + Token string `json:"token"` + } + json.Unmarshal(body, &alice) + + _, body = h.do("POST", "/api/v1/auth/register", "", map[string]string{"username": "bob", "password": "supersecret"}) + var bob struct { + User map[string]any `json:"user"` + Token string `json:"token"` + } + json.Unmarshal(body, &bob) + + // alice creates a private repo + code, body := h.do("POST", "/api/v1/repositories", alice.Token, map[string]any{ + "name": "myrepo", "visibility": "private", "description": "test", + }) + if code != 201 { + t.Fatalf("create repo status %d body %s", code, body) + } + + // bob cannot see it (private, not a member) + code, body = h.do("GET", "/api/v1/repositories/myrepo", bob.Token, nil) + if code != 403 { + t.Fatalf("bob should be forbidden from private repo, got %d", code) + } + + // alice grants bob read access + code, _ = h.do("POST", "/api/v1/repositories/myrepo/members", alice.Token, map[string]string{ + "username": "bob", "access": "read", + }) + if code != 201 { + t.Fatalf("add member status %d", code) + } + + // bob can now read + code, _ = h.do("GET", "/api/v1/repositories/myrepo", bob.Token, nil) + if code != 200 { + t.Fatalf("bob should read after grant, got %d", code) + } + + // bob cannot write (read only) + code, _ = h.do("POST", "/api/v1/repositories/myrepo/distributions", bob.Token, map[string]string{"name": "stable"}) + if code != 403 { + t.Fatalf("bob read-only should not write, got %d", code) + } + + // alice upgrades bob to write + h.do("PATCH", "/api/v1/repositories/myrepo/members/bob", alice.Token, map[string]string{"access": "write"}) + code, _ = h.do("POST", "/api/v1/repositories/myrepo/distributions", bob.Token, map[string]string{"name": "stable"}) + if code != 201 { + t.Fatalf("bob with write should create distro, got %d", code) + } + + // add component and arch + h.do("POST", "/api/v1/repositories/myrepo/distributions/stable/components", alice.Token, map[string]string{"name": "main"}) + code, _ = h.do("POST", "/api/v1/repositories/myrepo/distributions/stable/architectures", alice.Token, map[string]string{"name": "amd64"}) + if code != 201 { + t.Fatalf("add arch status %d", code) + } + // 'all' arch rejected + code, _ = h.do("POST", "/api/v1/repositories/myrepo/distributions/stable/architectures", alice.Token, map[string]string{"name": "all"}) + if code != 400 { + t.Fatalf("all arch should be rejected, got %d", code) + } + + // list distros/components/arches + code, _ = h.do("GET", "/api/v1/repositories/myrepo/distributions", alice.Token, nil) + if code != 200 { + t.Fatalf("list distros status %d", code) + } + code, _ = h.do("GET", "/api/v1/repositories/myrepo/distributions/stable/components", alice.Token, nil) + if code != 200 { + t.Fatalf("list components status %d", code) + } + + // remove member + code, _ = h.do("DELETE", "/api/v1/repositories/myrepo/members/bob", alice.Token, nil) + if code != 204 { + t.Fatalf("remove member status %d", code) + } + code, _ = h.do("GET", "/api/v1/repositories/myrepo", bob.Token, nil) + if code != 403 { + t.Fatalf("bob should be forbidden after removal, got %d", code) + } +} + +func TestPublicRepoReadableByAll(t *testing.T) { + h := newHarness(t) + _, body := h.do("POST", "/api/v1/auth/register", "", map[string]string{"username": "owner", "password": "supersecret"}) + var owner struct { + Token string `json:"token"` + } + json.Unmarshal(body, &owner) + _, body = h.do("POST", "/api/v1/auth/register", "", map[string]string{"username": "stranger", "password": "supersecret"}) + var stranger struct { + Token string `json:"token"` + } + json.Unmarshal(body, &stranger) + + h.do("POST", "/api/v1/repositories", owner.Token, map[string]any{"name": "pubrepo", "visibility": "public"}) + code, _ := h.do("GET", "/api/v1/repositories/pubrepo", stranger.Token, nil) + if code != 200 { + t.Fatalf("stranger should read public repo, got %d", code) + } + // but stranger cannot write + code, _ = h.do("POST", "/api/v1/repositories/pubrepo/distributions", stranger.Token, map[string]string{"name": "x"}) + if code != 403 { + t.Fatalf("stranger should not write public repo, got %d", code) + } +} diff --git a/server/restapi/server.go b/server/restapi/server.go new file mode 100644 index 0000000..2e59c94 --- /dev/null +++ b/server/restapi/server.go @@ -0,0 +1,44 @@ +package restapi + +import ( + "net/http" + + apitypes "urapt/shared/api" + "urapt/shared/httputil" + "urapt/shared/version" +) + +// ServerInfo returns build/setup metadata for the server. +func (api *API) ServerInfo(w http.ResponseWriter, r *http.Request) { + users, err := api.Store.ListUsers(r.Context()) + if err != nil { + httputil.WriteError(w, http.StatusInternalServerError, httputil.CodeInternal, "failed to read users") + return + } + fp := "" + if api.Signer != nil { + fp = api.Signer.Fingerprint() + } + httputil.WriteJSON(w, http.StatusOK, apitypes.ServerInfo{ + Version: version.Version, + NeedsSetup: len(users) == 0, + DefaultKeyFingerprint: fp, + OpenRegistration: api.Config.OpenRegistration, + }) +} + +// ServerPubkey returns the ASCII-armored default signing key. +func (api *API) ServerPubkey(w http.ResponseWriter, r *http.Request) { + if api.Signer == nil { + httputil.WriteError(w, http.StatusServiceUnavailable, httputil.CodeInternal, "no signing key configured") + return + } + pub, err := api.Signer.PublicKeyArmored() + if err != nil { + httputil.WriteError(w, http.StatusInternalServerError, httputil.CodeInternal, "failed to read key") + return + } + w.Header().Set("Content-Type", "application/pgp-keys") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(pub)) +} diff --git a/server/restapi/structure.go b/server/restapi/structure.go new file mode 100644 index 0000000..0cd0e6d --- /dev/null +++ b/server/restapi/structure.go @@ -0,0 +1,255 @@ +package restapi + +import ( + "errors" + "net/http" + + "urapt/server/store" + apitypes "urapt/shared/api" + "urapt/shared/httputil" + "urapt/shared/models" +) + +// validateName checks a distribution/component/architecture name. +func validateName(s string) bool { return nameRE.MatchString(s) } + +// loadDistro fetches the {repo}/{dist} distribution, rendering errors. +func (api *API) loadDistro(w http.ResponseWriter, r *http.Request) (*models.Repository, *models.Distribution) { + repo := api.requireRead(w, r) + if repo == nil { + return nil, nil + } + dist, err := api.Store.GetDistributionByName(r.Context(), repo.ID, r.PathValue("dist")) + if err != nil { + if errors.Is(err, store.ErrNotFound) { + httputil.WriteError(w, http.StatusNotFound, httputil.CodeNotFound, "distribution not found") + return repo, nil + } + httputil.WriteError(w, http.StatusInternalServerError, httputil.CodeInternal, "failed to read distribution") + return repo, nil + } + return repo, dist +} + +// requireDistroWrite loads repo (write) + distribution. +func (api *API) requireDistroWrite(w http.ResponseWriter, r *http.Request) (*models.Repository, *models.Distribution) { + repo := api.requireWrite(w, r) + if repo == nil { + return nil, nil + } + dist, err := api.Store.GetDistributionByName(r.Context(), repo.ID, r.PathValue("dist")) + if err != nil { + if errors.Is(err, store.ErrNotFound) { + httputil.WriteError(w, http.StatusNotFound, httputil.CodeNotFound, "distribution not found") + return repo, nil + } + httputil.WriteError(w, http.StatusInternalServerError, httputil.CodeInternal, "failed to read distribution") + return repo, nil + } + return repo, dist +} + +// --- distributions --- + +// ListDistributions returns the distributions in a repository. +func (api *API) ListDistributions(w http.ResponseWriter, r *http.Request) { + repo := api.requireRead(w, r) + if repo == nil { + return + } + dists, err := api.Store.ListDistributions(r.Context(), repo.ID) + if err != nil { + httputil.WriteError(w, http.StatusInternalServerError, httputil.CodeInternal, "failed to list distributions") + return + } + if dists == nil { + dists = []*models.Distribution{} + } + httputil.WriteJSON(w, http.StatusOK, dists) +} + +// CreateDistribution adds a distribution to a repository. +func (api *API) CreateDistribution(w http.ResponseWriter, r *http.Request) { + repo := api.requireWrite(w, r) + if repo == nil { + return + } + var req apitypes.CreateNamedRequest + if err := httputil.ReadJSON(r, &req, 1<<20); err != nil { + bad(w, "invalid JSON body") + return + } + if !validateName(req.Name) { + bad(w, "name must be 1-64 chars of [a-z0-9][a-z0-9+.-]") + return + } + if _, err := api.Store.GetDistributionByName(r.Context(), repo.ID, req.Name); err == nil { + httputil.WriteError(w, http.StatusConflict, httputil.CodeConflict, "distribution already exists") + return + } else if !errors.Is(err, store.ErrNotFound) { + httputil.WriteError(w, http.StatusInternalServerError, httputil.CodeInternal, "failed to check distribution") + return + } + dist, err := api.Store.CreateDistribution(r.Context(), repo.ID, req.Name) + if err != nil { + httputil.WriteError(w, http.StatusInternalServerError, httputil.CodeInternal, "failed to create distribution") + return + } + api.Cache.Invalidate(repo.ID, dist.Name) + httputil.WriteJSON(w, http.StatusCreated, dist) +} + +// DeleteDistribution removes a distribution (cascades to packages). +func (api *API) DeleteDistribution(w http.ResponseWriter, r *http.Request) { + repo, dist := api.requireDistroWrite(w, r) + if dist == nil { + return + } + if err := api.Store.DeleteDistribution(r.Context(), repo.ID, dist.Name); err != nil { + if errors.Is(err, store.ErrNotFound) { + httputil.WriteError(w, http.StatusNotFound, httputil.CodeNotFound, "distribution not found") + return + } + httputil.WriteError(w, http.StatusInternalServerError, httputil.CodeInternal, "failed to delete distribution") + return + } + // Note: cascaded package rows are gone; their blob ref counts are now + // stale. Best-effort cleanup of orphan blobs is handled by package delete + // in normal operation; bulk distribution deletion leaves blobs for now. + api.Cache.Invalidate(repo.ID, dist.Name) + w.WriteHeader(http.StatusNoContent) +} + +// --- components --- + +// ListComponents returns the components in a distribution. +func (api *API) ListComponents(w http.ResponseWriter, r *http.Request) { + _, dist := api.loadDistro(w, r) + if dist == nil { + return + } + comps, err := api.Store.ListComponents(r.Context(), dist.ID) + if err != nil { + httputil.WriteError(w, http.StatusInternalServerError, httputil.CodeInternal, "failed to list components") + return + } + if comps == nil { + comps = []*models.Component{} + } + httputil.WriteJSON(w, http.StatusOK, comps) +} + +// CreateComponent adds a component to a distribution. +func (api *API) CreateComponent(w http.ResponseWriter, r *http.Request) { + repo, dist := api.requireDistroWrite(w, r) + if dist == nil { + return + } + var req apitypes.CreateNamedRequest + if err := httputil.ReadJSON(r, &req, 1<<20); err != nil { + bad(w, "invalid JSON body") + return + } + if !validateName(req.Name) { + bad(w, "name must be 1-64 chars of [a-z0-9][a-z0-9+.-]") + return + } + if _, err := api.Store.GetComponentByName(r.Context(), dist.ID, req.Name); err == nil { + httputil.WriteError(w, http.StatusConflict, httputil.CodeConflict, "component already exists") + return + } else if !errors.Is(err, store.ErrNotFound) { + httputil.WriteError(w, http.StatusInternalServerError, httputil.CodeInternal, "failed to check component") + return + } + comp, err := api.Store.CreateComponent(r.Context(), dist.ID, req.Name) + if err != nil { + httputil.WriteError(w, http.StatusInternalServerError, httputil.CodeInternal, "failed to create component") + return + } + api.Cache.Invalidate(repo.ID, dist.Name) + httputil.WriteJSON(w, http.StatusCreated, comp) +} + +// DeleteComponent removes a component (blocked if packages reference it). +func (api *API) DeleteComponent(w http.ResponseWriter, r *http.Request) { + repo, dist := api.requireDistroWrite(w, r) + if dist == nil { + return + } + comp, err := api.Store.GetComponentByName(r.Context(), dist.ID, r.PathValue("comp")) + if err != nil { + httputil.WriteError(w, http.StatusNotFound, httputil.CodeNotFound, "component not found") + return + } + if err := api.Store.DeleteComponent(r.Context(), dist.ID, comp.Name); err != nil { + httputil.WriteError(w, http.StatusInternalServerError, httputil.CodeInternal, "failed to delete component (packages may still reference it)") + return + } + api.Cache.Invalidate(repo.ID, dist.Name) + w.WriteHeader(http.StatusNoContent) +} + +// --- architectures --- + +// ListArchitectures returns the architectures in a distribution. +func (api *API) ListArchitectures(w http.ResponseWriter, r *http.Request) { + _, dist := api.loadDistro(w, r) + if dist == nil { + return + } + arches, err := api.Store.ListArchitectures(r.Context(), dist.ID) + if err != nil { + httputil.WriteError(w, http.StatusInternalServerError, httputil.CodeInternal, "failed to list architectures") + return + } + if arches == nil { + arches = []*models.Architecture{} + } + httputil.WriteJSON(w, http.StatusOK, arches) +} + +// CreateArchitecture adds an architecture to a distribution. +func (api *API) CreateArchitecture(w http.ResponseWriter, r *http.Request) { + repo, dist := api.requireDistroWrite(w, r) + if dist == nil { + return + } + var req apitypes.CreateNamedRequest + if err := httputil.ReadJSON(r, &req, 1<<20); err != nil { + bad(w, "invalid JSON body") + return + } + if !validateName(req.Name) { + bad(w, "name must be 1-64 chars of [a-z0-9][a-z0-9+.-]") + return + } + if req.Name == "all" { + bad(w, "architecture 'all' is implicit and cannot be added") + return + } + arch, err := api.Store.CreateArchitecture(r.Context(), dist.ID, req.Name) + if err != nil { + httputil.WriteError(w, http.StatusInternalServerError, httputil.CodeInternal, "failed to create architecture (already exists?)") + return + } + api.Cache.Invalidate(repo.ID, dist.Name) + httputil.WriteJSON(w, http.StatusCreated, arch) +} + +// DeleteArchitecture removes an architecture from a distribution. +func (api *API) DeleteArchitecture(w http.ResponseWriter, r *http.Request) { + repo, dist := api.requireDistroWrite(w, r) + if dist == nil { + return + } + if err := api.Store.DeleteArchitecture(r.Context(), dist.ID, r.PathValue("arch")); err != nil { + if errors.Is(err, store.ErrNotFound) { + httputil.WriteError(w, http.StatusNotFound, httputil.CodeNotFound, "architecture not found") + return + } + httputil.WriteError(w, http.StatusInternalServerError, httputil.CodeInternal, "failed to delete architecture") + return + } + api.Cache.Invalidate(repo.ID, dist.Name) + w.WriteHeader(http.StatusNoContent) +} diff --git a/server/restapi/users.go b/server/restapi/users.go new file mode 100644 index 0000000..3876a16 --- /dev/null +++ b/server/restapi/users.go @@ -0,0 +1,91 @@ +package restapi + +import ( + "errors" + "net/http" + + "urapt/server/middleware" + "urapt/server/store" + apitypes "urapt/shared/api" + "urapt/shared/httputil" + "urapt/shared/models" +) + +// ListUsers returns all users (admin only). +func (api *API) ListUsers(w http.ResponseWriter, r *http.Request) { + users, err := api.Store.ListUsers(r.Context()) + if err != nil { + httputil.WriteError(w, http.StatusInternalServerError, httputil.CodeInternal, "failed to list users") + return + } + if users == nil { + users = []*models.User{} + } + httputil.WriteJSON(w, http.StatusOK, apitypes.ListResponse[*models.User]{Items: users, Page: 1, PerPage: 100, Total: len(users)}) +} + +// GetUser returns a single user by id (admin only). +func (api *API) GetUser(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + user, err := api.Store.GetUserByID(r.Context(), id) + if err != nil { + if errors.Is(err, store.ErrNotFound) { + httputil.WriteError(w, http.StatusNotFound, httputil.CodeNotFound, "user not found") + return + } + httputil.WriteError(w, http.StatusInternalServerError, httputil.CodeInternal, "failed to read user") + return + } + httputil.WriteJSON(w, http.StatusOK, user) +} + +// UpdateUser mutates a user (currently only is_admin) (admin only). +func (api *API) UpdateUser(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + var req apitypes.UpdateUserRequest + if err := httputil.ReadJSON(r, &req, 1<<20); err != nil { + bad(w, "invalid JSON body") + return + } + target, err := api.Store.GetUserByID(r.Context(), id) + if err != nil { + if errors.Is(err, store.ErrNotFound) { + httputil.WriteError(w, http.StatusNotFound, httputil.CodeNotFound, "user not found") + return + } + httputil.WriteError(w, http.StatusInternalServerError, httputil.CodeInternal, "failed to read user") + return + } + caller := middleware.IdentityFromContext(r.Context()) + if req.IsAdmin != nil { + if *req.IsAdmin && !caller.User.IsAdmin { + httputil.WriteError(w, http.StatusForbidden, httputil.CodeForbidden, "cannot grant admin") + return + } + if target.ID == caller.User.ID && !*req.IsAdmin { + httputil.WriteError(w, http.StatusBadRequest, httputil.CodeBadRequest, "cannot revoke your own admin") + return + } + } + if err := api.Store.UpdateUser(r.Context(), id, req.IsAdmin); err != nil { + httputil.WriteError(w, http.StatusInternalServerError, httputil.CodeInternal, "failed to update user") + return + } + updated, _ := api.Store.GetUserByID(r.Context(), id) + httputil.WriteJSON(w, http.StatusOK, updated) +} + +// DeleteUser removes a user (admin only). Self-deletion is blocked. +func (api *API) DeleteUser(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + caller := middleware.IdentityFromContext(r.Context()) + if id == caller.User.ID { + httputil.WriteError(w, http.StatusBadRequest, httputil.CodeBadRequest, "cannot delete your own account") + return + } + if err := api.Store.DeleteUser(r.Context(), id); err != nil { + httputil.WriteError(w, http.StatusInternalServerError, httputil.CodeInternal, "failed to delete user") + return + } + w.WriteHeader(http.StatusNoContent) +} diff --git a/server/store/audit.go b/server/store/audit.go new file mode 100644 index 0000000..b285f84 --- /dev/null +++ b/server/store/audit.go @@ -0,0 +1,19 @@ +package store + +import "context" + +// RecordAudit inserts a best-effort audit log entry. Errors are ignored at the +// call site's discretion; this helper returns the error for completeness. +func (s *Store) RecordAudit(ctx context.Context, userID, repositoryID *string, action, target, details string) error { + _, err := s.exec(ctx, `INSERT INTO audit_log (id, user_id, repository_id, action, target, details, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + newID(), nullable(userID), nullable(repositoryID), action, target, details, s.now()) + return err +} + +func nullable(p *string) any { + if p == nil { + return nil + } + return *p +} diff --git a/server/store/blobs.go b/server/store/blobs.go new file mode 100644 index 0000000..264aacf --- /dev/null +++ b/server/store/blobs.go @@ -0,0 +1,80 @@ +package store + +import ( + "context" + "fmt" +) + +// GetBlob returns a blob by its sha256, or ErrNotFound. +func (s *Store) GetBlob(ctx context.Context, sha256 string) (filename string, size, refCount int64, err error) { + err = s.db.QueryRowContext(ctx, `SELECT filename, size, ref_count FROM blobs WHERE sha256 = ?`, sha256). + Scan(&filename, &size, &refCount) + if isErrNoRows(err) { + return "", 0, 0, ErrNotFound + } + return filename, size, refCount, err +} + +// CreateBlob creates a new blob row with ref_count=1. It returns +// (created=true) when a new row was inserted, or (created=false) when the +// blob already existed (in which case its ref_count is left unchanged here; +// use IncBlobRef to bump it). +func (s *Store) CreateBlob(ctx context.Context, sha256, filename string, size int64) (created bool, err error) { + now := s.now() + res, err := s.exec(ctx, `INSERT OR IGNORE INTO blobs (sha256, filename, size, ref_count, created_at) VALUES (?, ?, ?, 1, ?)`, + sha256, filename, size, now) + if err != nil { + return false, fmt.Errorf("insert blob: %w", err) + } + n, _ := res.RowsAffected() + return n > 0, nil +} + +// IncBlobRef atomically increments a blob's ref_count and returns the new value. +func (s *Store) IncBlobRef(ctx context.Context, sha256 string) (int64, error) { + res, err := s.exec(ctx, `UPDATE blobs SET ref_count = ref_count + 1 WHERE sha256 = ?`, sha256) + if err != nil { + return 0, fmt.Errorf("inc blob: %w", err) + } + n, _ := res.RowsAffected() + if n == 0 { + return 0, ErrNotFound + } + var rc int64 + if err := s.db.QueryRowContext(ctx, `SELECT ref_count FROM blobs WHERE sha256 = ?`, sha256).Scan(&rc); err != nil { + return 0, err + } + return rc, nil +} + +// DecBlobRef atomically decrements a blob's ref_count and returns the new +// value. When it reaches 0 the caller should delete the on-disk file and call +// DeleteBlob. +func (s *Store) DecBlobRef(ctx context.Context, sha256 string) (int64, error) { + res, err := s.exec(ctx, `UPDATE blobs SET ref_count = ref_count - 1 WHERE sha256 = ? AND ref_count > 0`, sha256) + if err != nil { + return 0, fmt.Errorf("dec blob: %w", err) + } + n, _ := res.RowsAffected() + if n == 0 { + var rc int64 + if e := s.db.QueryRowContext(ctx, `SELECT ref_count FROM blobs WHERE sha256 = ?`, sha256).Scan(&rc); e != nil { + if isErrNoRows(e) { + return 0, ErrNotFound + } + return 0, e + } + return rc, nil + } + var rc int64 + if err := s.db.QueryRowContext(ctx, `SELECT ref_count FROM blobs WHERE sha256 = ?`, sha256).Scan(&rc); err != nil { + return 0, err + } + return rc, nil +} + +// DeleteBlob removes a blob row. +func (s *Store) DeleteBlob(ctx context.Context, sha256 string) error { + _, err := s.exec(ctx, `DELETE FROM blobs WHERE sha256 = ?`, sha256) + return err +} diff --git a/server/store/gpg.go b/server/store/gpg.go new file mode 100644 index 0000000..75853c5 --- /dev/null +++ b/server/store/gpg.go @@ -0,0 +1,53 @@ +package store + +import ( + "context" + "fmt" + + "urapt/shared/models" +) + +// SaveGPGKey inserts a GPG key row. +func (s *Store) SaveGPGKey(ctx context.Context, fingerprint, userID, pubArmored, privArmored string, isDefault bool) (*models.GPGKey, error) { + id := newID() + now := s.now() + _, err := s.exec(ctx, `INSERT INTO gpg_keys (id, fingerprint, user_id, public_key_armored, private_key_armored, is_default, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + id, fingerprint, userID, pubArmored, privArmored, boolToInt(isDefault), now) + if err != nil { + return nil, fmt.Errorf("insert gpg key: %w", err) + } + return &models.GPGKey{ + ID: id, Fingerprint: fingerprint, UserID: userID, + PublicKeyArmored: pubArmored, IsDefault: isDefault, CreatedAt: now, + }, nil +} + +// GetDefaultGPGKey returns the default signing key, or ErrNotFound if none. +func (s *Store) GetDefaultGPGKey(ctx context.Context) (*models.GPGKey, error) { + row := s.db.QueryRowContext(ctx, + `SELECT id, fingerprint, user_id, public_key_armored, private_key_armored, is_default, created_at + FROM gpg_keys WHERE is_default = 1 LIMIT 1`) + k := &models.GPGKey{} + var isDefault int + err := row.Scan(&k.ID, &k.Fingerprint, &k.UserID, &k.PublicKeyArmored, &k.PrivateKeyArmored, &isDefault, &k.CreatedAt) + if isErrNoRows(err) { + return nil, ErrNotFound + } + if err != nil { + return nil, err + } + k.IsDefault = isDefault == 1 + return k, nil +} + +// GetGPGKeyPublic returns just the armored public key of the default key. +func (s *Store) GetGPGKeyPublic(ctx context.Context) (string, error) { + row := s.db.QueryRowContext(ctx, `SELECT public_key_armored FROM gpg_keys WHERE is_default = 1 LIMIT 1`) + var pub string + err := row.Scan(&pub) + if isErrNoRows(err) { + return "", ErrNotFound + } + return pub, err +} diff --git a/server/store/gpg_test.go b/server/store/gpg_test.go new file mode 100644 index 0000000..6b6bb96 --- /dev/null +++ b/server/store/gpg_test.go @@ -0,0 +1,102 @@ +package store + +import ( + "context" + "errors" + "testing" +) + +func TestSaveGPGKeyAndGetDefault(t *testing.T) { + ctx := context.Background() + s := newTestStore(t) + + k, err := s.SaveGPGKey(ctx, "ABCD1234", "user-1", "PUB-ARMORED", "PRIV-ARMORED", true) + if err != nil { + t.Fatalf("save: %v", err) + } + if k.ID == "" || k.Fingerprint != "ABCD1234" || !k.IsDefault { + t.Fatalf("key = %+v", k) + } + + got, err := s.GetDefaultGPGKey(ctx) + if err != nil { + t.Fatalf("get default: %v", err) + } + if got.Fingerprint != "ABCD1234" || got.PublicKeyArmored != "PUB-ARMORED" { + t.Fatalf("got = %+v", got) + } + if got.PrivateKeyArmored != "PRIV-ARMORED" { + t.Fatal("private key armor should be retrieved from DB") + } + if !got.IsDefault { + t.Fatal("expected is_default=true") + } +} + +func TestGetDefaultGPGKey_None(t *testing.T) { + ctx := context.Background() + s := newTestStore(t) + if _, err := s.GetDefaultGPGKey(ctx); !errors.Is(err, ErrNotFound) { + t.Fatalf("expected ErrNotFound when no key, got %v", err) + } +} + +func TestGetGPGKeyPublic(t *testing.T) { + ctx := context.Background() + s := newTestStore(t) + _, _ = s.SaveGPGKey(ctx, "ABCD1234", "user-1", "PUB-ARMORED", "PRIV-ARMORED", true) + + pub, err := s.GetGPGKeyPublic(ctx) + if err != nil { + t.Fatalf("get public: %v", err) + } + if pub != "PUB-ARMORED" { + t.Fatalf("pub = %q", pub) + } + // No default key. + s2 := newTestStore(t) + if _, err := s2.GetGPGKeyPublic(ctx); !errors.Is(err, ErrNotFound) { + t.Fatalf("expected ErrNotFound, got %v", err) + } +} + +// --- audit --- + +func TestRecordAudit(t *testing.T) { + ctx := context.Background() + s := newTestStore(t) + // audit_log has FK constraints on user_id/repo_id, so use real rows. + u := s.createUser(t, ctx, "alice", "p") + repo := s.createRepo(t, ctx, "repo", u.ID, "public") + uid, repoID := u.ID, repo.ID + + // Insert with both user and repo set. + if err := s.RecordAudit(ctx, &uid, &repoID, "push", "pkg-1", "uploaded myapp"); err != nil { + t.Fatalf("record: %v", err) + } + // Insert with nil pointers (system action). + if err := s.RecordAudit(ctx, nil, nil, "startup", "server", "started"); err != nil { + t.Fatalf("record nil: %v", err) + } + + // Verify rows exist. The audit_log table is write-only from the store API; + // query directly to confirm persistence. + var n int + err := s.DB().QueryRowContext(ctx, `SELECT COUNT(*) FROM audit_log`).Scan(&n) + if err != nil { + t.Fatalf("count: %v", err) + } + if n != 2 { + t.Fatalf("expected 2 audit rows, got %d", n) + } + + // Verify nullable columns are stored correctly. + var uidVal, repoVal *string + row := s.DB().QueryRowContext(ctx, `SELECT user_id, repository_id FROM audit_log WHERE action = 'startup'`) + if err := row.Scan(&uidVal, &repoVal); err != nil { + t.Fatalf("scan startup row: %v", err) + } + if uidVal != nil || repoVal != nil { + t.Fatalf("expected nil user_id/repository_id for system action, got %v %v", uidVal, repoVal) + } +} diff --git a/server/store/members.go b/server/store/members.go new file mode 100644 index 0000000..6f5b9d4 --- /dev/null +++ b/server/store/members.go @@ -0,0 +1,76 @@ +package store + +import ( + "context" + "fmt" + + "urapt/shared/models" +) + +// AddMember grants a user access on a repository. If a grant already exists it +// is updated. +func (s *Store) AddMember(ctx context.Context, repoID, userID string, access models.Access) error { + now := s.now() + _, err := s.exec(ctx, `INSERT INTO repository_members (repository_id, user_id, access, created_at) + VALUES (?, ?, ?, ?) + ON CONFLICT(repository_id, user_id) DO UPDATE SET access = excluded.access`, + repoID, userID, string(access), now) + if err != nil { + return fmt.Errorf("upsert member: %w", err) + } + return nil +} + +// UpdateMemberAccess changes a user's access on a repository. +func (s *Store) UpdateMemberAccess(ctx context.Context, repoID, userID string, access models.Access) error { + res, err := s.exec(ctx, `UPDATE repository_members SET access = ? WHERE repository_id = ? AND user_id = ?`, + string(access), repoID, userID) + if err != nil { + return fmt.Errorf("update member: %w", err) + } + n, _ := res.RowsAffected() + if n == 0 { + return ErrNotFound + } + return nil +} + +// RemoveMember revokes a user's access on a repository. +func (s *Store) RemoveMember(ctx context.Context, repoID, userID string) error { + res, err := s.exec(ctx, `DELETE FROM repository_members WHERE repository_id = ? AND user_id = ?`, repoID, userID) + if err != nil { + return fmt.Errorf("remove member: %w", err) + } + n, _ := res.RowsAffected() + if n == 0 { + return ErrNotFound + } + return nil +} + +// ListMembers returns all members of a repository with their user records. +func (s *Store) ListMembers(ctx context.Context, repoID string) ([]*models.RepositoryMember, error) { + rows, err := s.db.QueryContext(ctx, ` + SELECT m.repository_id, m.user_id, m.access, m.created_at, + u.id, u.username, u.is_admin, u.created_at, u.updated_at + FROM repository_members m + JOIN users u ON u.id = m.user_id + WHERE m.repository_id = ? + ORDER BY u.username`, repoID) + if err != nil { + return nil, fmt.Errorf("list members: %w", err) + } + defer rows.Close() + var out []*models.RepositoryMember + for rows.Next() { + m := &models.RepositoryMember{User: &models.User{}} + if err := rows.Scan( + &m.RepositoryID, &m.UserID, &m.Access, &m.CreatedAt, + &m.User.ID, &m.User.Username, &m.User.IsAdmin, &m.User.CreatedAt, &m.User.UpdatedAt, + ); err != nil { + return nil, err + } + out = append(out, m) + } + return out, rows.Err() +} diff --git a/server/store/packages.go b/server/store/packages.go new file mode 100644 index 0000000..0e44176 --- /dev/null +++ b/server/store/packages.go @@ -0,0 +1,236 @@ +package store + +import ( + "context" + "database/sql" + "fmt" + "strings" + + "urapt/shared/models" +) + +// SuitePackage is a package row joined with its component and distribution +// names, used by the APT index generator. +type SuitePackage struct { + models.Package + ComponentName string + DistributionName string +} + +// CreatePackage inserts a new package row. +func (s *Store) CreatePackage(ctx context.Context, p *models.Package) error { + if p.ID == "" { + p.ID = newID() + } + if p.CreatedAt == "" { + p.CreatedAt = s.now() + } + _, err := s.exec(ctx, `INSERT INTO packages ( + id, repository_id, distribution_id, component_id, + name, version, architecture, source, maintainer, priority, section, + origin, homepage, description, description_md5, depends, pre_depends, + recommends, suggests, conflicts, breaks, provides, replaces, enhances, + installed_size, essential, built_using, tag, raw_control, filename, + pool_path, size, md5sum, sha1, sha256, uploaded_by_user_id, created_at + ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, + p.ID, p.RepositoryID, p.DistributionID, p.ComponentID, + p.Name, p.Version, p.Architecture, p.Source, p.Maintainer, p.Priority, p.Section, + p.Origin, p.Homepage, p.Description, p.DescriptionMD5, p.Depends, p.PreDepends, + p.Recommends, p.Suggests, p.Conflicts, p.Breaks, p.Provides, p.Replaces, p.Enhances, + p.InstalledSize, p.Essential, p.BuiltUsing, p.Tag, p.RawControl, p.Filename, + p.PoolPath, p.Size, p.MD5sum, p.SHA1, p.SHA256, p.UploadedByUserID, p.CreatedAt, + ) + if err != nil { + return fmt.Errorf("insert package: %w", err) + } + return nil +} + +// GetPackageByID returns a package by id. +func (s *Store) GetPackageByID(ctx context.Context, id string) (*models.Package, error) { + row := s.db.QueryRowContext(ctx, packageCols+` FROM packages WHERE id = ?`, id) + return scanPackage(row) +} + +// GetPackageByPoolPath returns a package within a repository by its pool_path. +func (s *Store) GetPackageByPoolPath(ctx context.Context, repoID, poolPath string) (*models.Package, error) { + row := s.db.QueryRowContext(ctx, packageCols+` FROM packages WHERE repository_id = ? AND pool_path = ?`, repoID, poolPath) + return scanPackage(row) +} + +// PackageFilters controls ListPackages filtering. +type PackageFilters struct { + ComponentID string + Arch string + Name string + Query string +} + +// ListPackages lists packages within a (repo, distribution) with optional +// filters and pagination. +func (s *Store) ListPackages(ctx context.Context, repoID, distroID string, f PackageFilters, page, perPage int) ([]*models.Package, int, error) { + if page < 1 { + page = 1 + } + if perPage < 1 || perPage > 100 { + perPage = 25 + } + var where []string + var args []any + where = append(where, "repository_id = ?", "distribution_id = ?") + args = append(args, repoID, distroID) + if f.ComponentID != "" { + where = append(where, "component_id = ?") + args = append(args, f.ComponentID) + } + if f.Arch != "" { + where = append(where, "(architecture = ? OR architecture = 'all')") + args = append(args, f.Arch) + } + if f.Name != "" { + where = append(where, "name = ?") + args = append(args, f.Name) + } + if f.Query != "" { + where = append(where, "(name LIKE ? OR description LIKE ?)") + args = append(args, "%"+f.Query+"%", "%"+f.Query+"%") + } + q := strings.Join(where, " AND ") + + var total int + if err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM packages WHERE `+q, args...).Scan(&total); err != nil { + return nil, 0, fmt.Errorf("count packages: %w", err) + } + args2 := append(args, perPage, (page-1)*perPage) + rows, err := s.db.QueryContext(ctx, packageCols+` FROM packages WHERE `+q+` ORDER BY name, version LIMIT ? OFFSET ?`, args2...) + if err != nil { + return nil, 0, fmt.Errorf("list packages: %w", err) + } + defer rows.Close() + var out []*models.Package + for rows.Next() { + p, err := scanPackageRows(rows) + if err != nil { + return nil, 0, err + } + out = append(out, p) + } + return out, total, rows.Err() +} + +// ListSuitePackages returns all packages in a (repo, distribution) joined with +// their component and distribution names, for APT index generation. +func (s *Store) ListSuitePackages(ctx context.Context, repoID, distroID string) ([]SuitePackage, error) { + cols := qualifiedPackageCols("p") + rows, err := s.db.QueryContext(ctx, ` + SELECT `+cols+`, c.name, d.name + FROM packages p + JOIN components c ON c.id = p.component_id + JOIN distributions d ON d.id = p.distribution_id + WHERE p.repository_id = ? AND p.distribution_id = ?`, repoID, distroID) + if err != nil { + return nil, fmt.Errorf("list suite packages: %w", err) + } + defer rows.Close() + var out []SuitePackage + for rows.Next() { + var sp SuitePackage + if err := scanPackageColsFull(rows.Scan, &sp.Package, &sp.ComponentName, &sp.DistributionName); err != nil { + return nil, err + } + out = append(out, sp) + } + return out, rows.Err() +} + +// scanPackageColsFull scans the 37 package columns plus the joined component +// name and distribution name. +func scanPackageColsFull(scan scanFunc, p *models.Package, compName, distName *string) error { + return scan( + &p.ID, &p.RepositoryID, &p.DistributionID, &p.ComponentID, + &p.Name, &p.Version, &p.Architecture, &p.Source, &p.Maintainer, &p.Priority, &p.Section, + &p.Origin, &p.Homepage, &p.Description, &p.DescriptionMD5, &p.Depends, &p.PreDepends, + &p.Recommends, &p.Suggests, &p.Conflicts, &p.Breaks, &p.Provides, &p.Replaces, &p.Enhances, + &p.InstalledSize, &p.Essential, &p.BuiltUsing, &p.Tag, &p.RawControl, &p.Filename, + &p.PoolPath, &p.Size, &p.MD5sum, &p.SHA1, &p.SHA256, &p.UploadedByUserID, &p.CreatedAt, + compName, distName, + ) +} + +// ListPackageBlobSHA256sByRepo returns the sha256 of every package in a repo +// (with duplicates), used during repository deletion to decrement ref counts. +func (s *Store) ListPackageBlobSHA256sByRepo(ctx context.Context, repoID string) ([]string, error) { + rows, err := s.db.QueryContext(ctx, `SELECT sha256 FROM packages WHERE repository_id = ?`, repoID) + if err != nil { + return nil, fmt.Errorf("list repo blobs: %w", err) + } + defer rows.Close() + var out []string + for rows.Next() { + var sha string + if err := rows.Scan(&sha); err != nil { + return nil, err + } + out = append(out, sha) + } + return out, rows.Err() +} + +// DeletePackage removes a package row by id. +func (s *Store) DeletePackage(ctx context.Context, id string) error { + _, err := s.exec(ctx, `DELETE FROM packages WHERE id = ?`, id) + return err +} + +// DeletePackagesByRepo removes all package rows in a repository. +func (s *Store) DeletePackagesByRepo(ctx context.Context, repoID string) error { + _, err := s.exec(ctx, `DELETE FROM packages WHERE repository_id = ?`, repoID) + return err +} + +const packageColNames = `id, repository_id, distribution_id, component_id, name, version, architecture, source, maintainer, priority, section, origin, homepage, description, description_md5, depends, pre_depends, recommends, suggests, conflicts, breaks, provides, replaces, enhances, installed_size, essential, built_using, tag, raw_control, filename, pool_path, size, md5sum, sha1, sha256, uploaded_by_user_id, created_at` + +const packageCols = `SELECT ` + packageColNames + +// qualifiedPackageCols returns the package column list prefixed with alias., +// e.g. "p.id, p.repository_id, ...", for use in JOINs. +func qualifiedPackageCols(alias string) string { + parts := strings.Split(packageColNames, ", ") + for i, p := range parts { + parts[i] = alias + "." + p + } + return strings.Join(parts, ", ") +} + +func scanPackage(row *sql.Row) (*models.Package, error) { + p := &models.Package{} + if err := scanPackageCols(row.Scan, p); err != nil { + if isErrNoRows(err) { + return nil, ErrNotFound + } + return nil, err + } + return p, nil +} + +func scanPackageRows(rows *sql.Rows) (*models.Package, error) { + p := &models.Package{} + if err := scanPackageCols(rows.Scan, p); err != nil { + return nil, err + } + return p, nil +} + +// scanFunc abstracts *sql.Row.Scan and *sql.Rows.Scan. +type scanFunc func(dest ...any) error + +func scanPackageCols(scan scanFunc, p *models.Package) error { + return scan( + &p.ID, &p.RepositoryID, &p.DistributionID, &p.ComponentID, + &p.Name, &p.Version, &p.Architecture, &p.Source, &p.Maintainer, &p.Priority, &p.Section, + &p.Origin, &p.Homepage, &p.Description, &p.DescriptionMD5, &p.Depends, &p.PreDepends, + &p.Recommends, &p.Suggests, &p.Conflicts, &p.Breaks, &p.Provides, &p.Replaces, &p.Enhances, + &p.InstalledSize, &p.Essential, &p.BuiltUsing, &p.Tag, &p.RawControl, &p.Filename, + &p.PoolPath, &p.Size, &p.MD5sum, &p.SHA1, &p.SHA256, &p.UploadedByUserID, &p.CreatedAt, + ) +} diff --git a/server/store/packages_test.go b/server/store/packages_test.go new file mode 100644 index 0000000..2a6f69e --- /dev/null +++ b/server/store/packages_test.go @@ -0,0 +1,366 @@ +package store + +import ( + "context" + "errors" + "testing" + + "urapt/shared/models" +) + +// newPackage creates a minimal valid Package row ready to insert. +func newPackage(repoID, distroID, compID, userID string) *models.Package { + return &models.Package{ + RepositoryID: repoID, + DistributionID: distroID, + ComponentID: compID, + Name: "myapp-hello", + Version: "1.0.0", + Architecture: "amd64", + Maintainer: "Test ", + Description: "a test package", + RawControl: "Package: myapp-hello\nVersion: 1.0.0\n", + Filename: "myapp-hello_1.0.0_amd64.deb", + PoolPath: "pool/main/m/myapp-hello/myapp-hello_1.0.0_amd64.deb", + Size: 712, + MD5sum: "d41d8cd98f00b204e9800998ecf8427e", + SHA1: "da39a3ee5e6b4b0d3255bfef95601890afd80709", + SHA256: "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + UploadedByUserID: userID, + } +} + +func TestCreatePackageAndGetByID(t *testing.T) { + ctx := context.Background() + s := newTestStore(t) + u := s.createUser(t, ctx, "alice", "p") + repo := s.createRepo(t, ctx, "repo", u.ID, models.VisibilityPublic) + d := s.createDistro(t, ctx, repo.ID, "stable") + c := s.createComponent(t, ctx, d.ID, "main") + _ = c + + p := newPackage(repo.ID, d.ID, c.ID, u.ID) + if err := s.CreatePackage(ctx, p); err != nil { + t.Fatalf("create: %v", err) + } + if p.ID == "" || p.CreatedAt == "" { + t.Fatal("id/created_at should be populated by CreatePackage") + } + + got, err := s.GetPackageByID(ctx, p.ID) + if err != nil { + t.Fatalf("get by id: %v", err) + } + if got.Name != "myapp-hello" || got.Version != "1.0.0" || got.Architecture != "amd64" { + t.Fatalf("got = %+v", got) + } + if got.SHA256 != p.SHA256 { + t.Fatalf("sha256 mismatch") + } +} + +func TestGetPackageByID_NotFound(t *testing.T) { + ctx := context.Background() + s := newTestStore(t) + if _, err := s.GetPackageByID(ctx, "nope"); !errors.Is(err, ErrNotFound) { + t.Fatalf("expected ErrNotFound, got %v", err) + } +} + +func TestGetPackageByPoolPath(t *testing.T) { + ctx := context.Background() + s := newTestStore(t) + u := s.createUser(t, ctx, "alice", "p") + repo := s.createRepo(t, ctx, "repo", u.ID, models.VisibilityPublic) + d := s.createDistro(t, ctx, repo.ID, "stable") + c := s.createComponent(t, ctx, d.ID, "main") + + p := newPackage(repo.ID, d.ID, c.ID, u.ID) + _ = s.CreatePackage(ctx, p) + + got, err := s.GetPackageByPoolPath(ctx, repo.ID, p.PoolPath) + if err != nil { + t.Fatalf("get by pool path: %v", err) + } + if got.ID != p.ID { + t.Fatal("id mismatch") + } + // Wrong repo. + if _, err := s.GetPackageByPoolPath(ctx, "other-repo", p.PoolPath); !errors.Is(err, ErrNotFound) { + t.Fatalf("expected ErrNotFound for wrong repo, got %v", err) + } +} + +func TestListPackages_FiltersAndPagination(t *testing.T) { + ctx := context.Background() + s := newTestStore(t) + u := s.createUser(t, ctx, "alice", "p") + repo := s.createRepo(t, ctx, "repo", u.ID, models.VisibilityPublic) + d := s.createDistro(t, ctx, repo.ID, "stable") + main := s.createComponent(t, ctx, d.ID, "main") + contrib := s.createComponent(t, ctx, d.ID, "contrib") + + // Insert 5 packages: 3 in main, 2 in contrib; mix of amd64 and arm64. + pkgs := []*models.Package{ + newPackage(repo.ID, d.ID, main.ID, u.ID), + newPackage(repo.ID, d.ID, main.ID, u.ID), + newPackage(repo.ID, d.ID, main.ID, u.ID), + newPackage(repo.ID, d.ID, contrib.ID, u.ID), + newPackage(repo.ID, d.ID, contrib.ID, u.ID), + } + names := []string{"alpha", "beta", "gamma", "delta", "epsilon"} + arches := []string{"amd64", "amd64", "arm64", "amd64", "arm64"} + for i, p := range pkgs { + p.Name = names[i] + p.Architecture = arches[i] + p.PoolPath = "pool/main/m/" + names[i] + "/" + names[i] + "_1.0.0_" + arches[i] + ".deb" + p.Filename = names[i] + "_1.0.0_" + arches[i] + ".deb" + if err := s.CreatePackage(ctx, p); err != nil { + t.Fatalf("create %d: %v", i, err) + } + } + + // All. + list, total, err := s.ListPackages(ctx, repo.ID, d.ID, PackageFilters{}, 1, 100) + if err != nil { + t.Fatalf("list all: %v", err) + } + if total != 5 || len(list) != 5 { + t.Fatalf("expected total=5 len=5, got total=%d len=%d", total, len(list)) + } + + // Filter by component. + list, total, _ = s.ListPackages(ctx, repo.ID, d.ID, PackageFilters{ComponentID: main.ID}, 1, 100) + if total != 3 || len(list) != 3 { + t.Fatalf("main filter: expected 3, got total=%d len=%d", total, len(list)) + } + + // Filter by arch (matches arch OR 'all'). + list, total, _ = s.ListPackages(ctx, repo.ID, d.ID, PackageFilters{Arch: "amd64"}, 1, 100) + if total != 3 { // alpha, beta, delta + t.Fatalf("amd64 filter: expected 3, got %d", total) + } + list, total, _ = s.ListPackages(ctx, repo.ID, d.ID, PackageFilters{Arch: "arm64"}, 1, 100) + if total != 2 { // gamma, epsilon + t.Fatalf("arm64 filter: expected 2, got %d", total) + } + + // Filter by exact name. + list, total, _ = s.ListPackages(ctx, repo.ID, d.ID, PackageFilters{Name: "beta"}, 1, 100) + if total != 1 || len(list) != 1 || list[0].Name != "beta" { + t.Fatalf("name filter: total=%d list=%v", total, list) + } + + // Query (LIKE on name/description). + list, total, _ = s.ListPackages(ctx, repo.ID, d.ID, PackageFilters{Query: "a test"}, 1, 100) + if total != 5 { + t.Fatalf("query filter: expected all 5 to match description, got %d", total) + } + + // Pagination: page 1, perPage 2 -> 2 items, total 5. + list, total, _ = s.ListPackages(ctx, repo.ID, d.ID, PackageFilters{}, 1, 2) + if total != 5 || len(list) != 2 { + t.Fatalf("page 1: total=%d len=%d", total, len(list)) + } + // page 3 -> only 1 item. + list, total, _ = s.ListPackages(ctx, repo.ID, d.ID, PackageFilters{}, 3, 2) + if len(list) != 1 { + t.Fatalf("page 3: expected 1 item, got %d", len(list)) + } + _ = list +} + +func TestDeletePackage(t *testing.T) { + ctx := context.Background() + s := newTestStore(t) + u := s.createUser(t, ctx, "alice", "p") + repo := s.createRepo(t, ctx, "repo", u.ID, models.VisibilityPublic) + d := s.createDistro(t, ctx, repo.ID, "stable") + c := s.createComponent(t, ctx, d.ID, "main") + p := newPackage(repo.ID, d.ID, c.ID, u.ID) + _ = s.CreatePackage(ctx, p) + + if err := s.DeletePackage(ctx, p.ID); err != nil { + t.Fatalf("delete: %v", err) + } + if _, err := s.GetPackageByID(ctx, p.ID); !errors.Is(err, ErrNotFound) { + t.Fatalf("expected ErrNotFound after delete, got %v", err) + } +} + +func TestDeletePackagesByRepo(t *testing.T) { + ctx := context.Background() + s := newTestStore(t) + u := s.createUser(t, ctx, "alice", "p") + repo := s.createRepo(t, ctx, "repo", u.ID, models.VisibilityPublic) + d := s.createDistro(t, ctx, repo.ID, "stable") + c := s.createComponent(t, ctx, d.ID, "main") + _ = s.CreatePackage(ctx, newPackage(repo.ID, d.ID, c.ID, u.ID)) + _ = s.CreatePackage(ctx, newPackage(repo.ID, d.ID, c.ID, u.ID)) + + if err := s.DeletePackagesByRepo(ctx, repo.ID); err != nil { + t.Fatalf("delete by repo: %v", err) + } + list, total, _ := s.ListPackages(ctx, repo.ID, d.ID, PackageFilters{}, 1, 100) + if total != 0 || len(list) != 0 { + t.Fatalf("expected no packages after DeletePackagesByRepo, got total=%d", total) + } +} + +func TestListSuitePackages(t *testing.T) { + ctx := context.Background() + s := newTestStore(t) + u := s.createUser(t, ctx, "alice", "p") + repo := s.createRepo(t, ctx, "repo", u.ID, models.VisibilityPublic) + d := s.createDistro(t, ctx, repo.ID, "stable") + c := s.createComponent(t, ctx, d.ID, "main") + p := newPackage(repo.ID, d.ID, c.ID, u.ID) + _ = s.CreatePackage(ctx, p) + + suite, err := s.ListSuitePackages(ctx, repo.ID, d.ID) + if err != nil { + t.Fatalf("list suite: %v", err) + } + if len(suite) != 1 { + t.Fatalf("expected 1 suite package, got %d", len(suite)) + } + if suite[0].ComponentName != "main" || suite[0].DistributionName != "stable" { + t.Fatalf("component=%q distro=%q", suite[0].ComponentName, suite[0].DistributionName) + } + if suite[0].Name != "myapp-hello" { + t.Fatalf("name=%q", suite[0].Name) + } +} + +func TestListPackageBlobSHA256sByRepo(t *testing.T) { + ctx := context.Background() + s := newTestStore(t) + u := s.createUser(t, ctx, "alice", "p") + repo := s.createRepo(t, ctx, "repo", u.ID, models.VisibilityPublic) + d := s.createDistro(t, ctx, repo.ID, "stable") + c := s.createComponent(t, ctx, d.ID, "main") + + p1 := newPackage(repo.ID, d.ID, c.ID, u.ID) + p1.Name = "alpha" + p1.SHA256 = "aaa" + p1.PoolPath = "pool/main/m/alpha/alpha.deb" + p1.Filename = "alpha.deb" + _ = s.CreatePackage(ctx, p1) + p2 := newPackage(repo.ID, d.ID, c.ID, u.ID) + p2.Name = "beta" + p2.SHA256 = "bbb" + p2.PoolPath = "pool/main/m/beta/beta.deb" + p2.Filename = "beta.deb" + _ = s.CreatePackage(ctx, p2) + + shas, err := s.ListPackageBlobSHA256sByRepo(ctx, repo.ID) + if err != nil { + t.Fatalf("list: %v", err) + } + if len(shas) != 2 { + t.Fatalf("expected 2 shas, got %d", len(shas)) + } +} + +// --- blobs --- + +func TestCreateBlob_NewAndExisting(t *testing.T) { + ctx := context.Background() + s := newTestStore(t) + + created, err := s.CreateBlob(ctx, "sha-aaa", "hello.deb", 712) + if err != nil { + t.Fatalf("create: %v", err) + } + if !created { + t.Fatal("first CreateBlob should report created=true") + } + // Second time, same sha -> not created, no error. + created, err = s.CreateBlob(ctx, "sha-aaa", "hello.deb", 712) + if err != nil { + t.Fatalf("second create: %v", err) + } + if created { + t.Fatal("second CreateBlob should report created=false (already exists)") + } +} + +func TestGetBlob(t *testing.T) { + ctx := context.Background() + s := newTestStore(t) + _, _ = s.CreateBlob(ctx, "sha-aaa", "hello.deb", 712) + + filename, size, refCount, err := s.GetBlob(ctx, "sha-aaa") + if err != nil { + t.Fatalf("get: %v", err) + } + if filename != "hello.deb" || size != 712 || refCount != 1 { + t.Fatalf("got filename=%q size=%d ref=%d", filename, size, refCount) + } + if _, _, _, err := s.GetBlob(ctx, "missing"); !errors.Is(err, ErrNotFound) { + t.Fatalf("expected ErrNotFound, got %v", err) + } +} + +func TestIncBlobRef(t *testing.T) { + ctx := context.Background() + s := newTestStore(t) + _, _ = s.CreateBlob(ctx, "sha-aaa", "hello.deb", 712) + + rc, err := s.IncBlobRef(ctx, "sha-aaa") + if err != nil { + t.Fatalf("inc: %v", err) + } + if rc != 2 { + t.Fatalf("expected ref=2 after inc, got %d", rc) + } + rc, _ = s.IncBlobRef(ctx, "sha-aaa") + if rc != 3 { + t.Fatalf("expected ref=3, got %d", rc) + } + // Inc on missing blob. + if _, err := s.IncBlobRef(ctx, "missing"); !errors.Is(err, ErrNotFound) { + t.Fatalf("expected ErrNotFound, got %v", err) + } +} + +func TestDecBlobRef(t *testing.T) { + ctx := context.Background() + s := newTestStore(t) + _, _ = s.CreateBlob(ctx, "sha-aaa", "hello.deb", 712) + _, _ = s.IncBlobRef(ctx, "sha-aaa") // ref=2 + + rc, err := s.DecBlobRef(ctx, "sha-aaa") + if err != nil { + t.Fatalf("dec: %v", err) + } + if rc != 1 { + t.Fatalf("expected ref=1 after dec, got %d", rc) + } + rc, _ = s.DecBlobRef(ctx, "sha-aaa") + if rc != 0 { + t.Fatalf("expected ref=0, got %d", rc) + } + // Dec below 0 should not go negative; ref_count > 0 guard. + rc, _ = s.DecBlobRef(ctx, "sha-aaa") + if rc != 0 { + t.Fatalf("expected ref to stay at 0, got %d", rc) + } + // Dec on missing blob. + if _, err := s.DecBlobRef(ctx, "totally-missing"); !errors.Is(err, ErrNotFound) { + t.Fatalf("expected ErrNotFound for missing, got %v", err) + } +} + +func TestDeleteBlob(t *testing.T) { + ctx := context.Background() + s := newTestStore(t) + _, _ = s.CreateBlob(ctx, "sha-aaa", "hello.deb", 712) + + if err := s.DeleteBlob(ctx, "sha-aaa"); err != nil { + t.Fatalf("delete: %v", err) + } + if _, _, _, err := s.GetBlob(ctx, "sha-aaa"); !errors.Is(err, ErrNotFound) { + t.Fatalf("expected ErrNotFound after delete, got %v", err) + } +} diff --git a/server/store/repos.go b/server/store/repos.go new file mode 100644 index 0000000..bbdcf80 --- /dev/null +++ b/server/store/repos.go @@ -0,0 +1,125 @@ +package store + +import ( + "context" + "database/sql" + "fmt" + + "urapt/shared/models" +) + +// CreateRepository inserts a new repository owned by userID. +func (s *Store) CreateRepository(ctx context.Context, name, ownerUserID string, visibility models.Visibility, description string) (*models.Repository, error) { + id := newID() + now := s.now() + _, err := s.exec(ctx, `INSERT INTO repositories (id, name, owner_user_id, visibility, description, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?)`, id, name, ownerUserID, string(visibility), description, now, now) + if err != nil { + return nil, fmt.Errorf("insert repository: %w", err) + } + return &models.Repository{ + ID: id, Name: name, OwnerUserID: ownerUserID, Visibility: visibility, + Description: description, CreatedAt: now, UpdatedAt: now, + }, nil +} + +// ListReposVisible returns repositories visible to userID: owned, public, or +// where the user is a member. +func (s *Store) ListReposVisible(ctx context.Context, userID string) ([]*models.Repository, error) { + rows, err := s.db.QueryContext(ctx, ` + SELECT DISTINCT r.id, r.name, r.owner_user_id, r.visibility, r.description, r.created_at, r.updated_at + FROM repositories r + WHERE r.owner_user_id = ? + OR r.visibility = 'public' + OR EXISTS (SELECT 1 FROM repository_members m WHERE m.repository_id = r.id AND m.user_id = ?) + ORDER BY r.name`, userID, userID) + if err != nil { + return nil, fmt.Errorf("list repos: %w", err) + } + defer rows.Close() + var out []*models.Repository + for rows.Next() { + r := &models.Repository{} + if err := rows.Scan(&r.ID, &r.Name, &r.OwnerUserID, &r.Visibility, &r.Description, &r.CreatedAt, &r.UpdatedAt); err != nil { + return nil, err + } + out = append(out, r) + } + return out, rows.Err() +} + +// UpdateRepository mutates a repository's name, visibility, and description. +// Empty strings leave the field unchanged. +func (s *Store) UpdateRepository(ctx context.Context, id, name string, visibility *models.Visibility, description *string) error { + now := s.now() + if name != "" { + if _, err := s.exec(ctx, `UPDATE repositories SET name = ?, updated_at = ? WHERE id = ?`, name, now, id); err != nil { + return fmt.Errorf("update repo name: %w", err) + } + } + if visibility != nil { + if _, err := s.exec(ctx, `UPDATE repositories SET visibility = ?, updated_at = ? WHERE id = ?`, string(*visibility), now, id); err != nil { + return fmt.Errorf("update repo visibility: %w", err) + } + } + if description != nil { + if _, err := s.exec(ctx, `UPDATE repositories SET description = ?, updated_at = ? WHERE id = ?`, *description, now, id); err != nil { + return fmt.Errorf("update repo description: %w", err) + } + } + return nil +} + +// DeleteRepository deletes a repository and all its child rows (cascade). +// The caller must have already handled blob ref-count cleanup. +func (s *Store) DeleteRepository(ctx context.Context, id string) error { + if _, err := s.exec(ctx, `DELETE FROM repositories WHERE id = ?`, id); err != nil { + return fmt.Errorf("delete repository: %w", err) + } + return nil +} + +// GetRepositoryByName returns a repository by its unique name. +func (s *Store) GetRepositoryByName(ctx context.Context, name string) (*models.Repository, error) { + row := s.db.QueryRowContext(ctx, + `SELECT id, name, owner_user_id, visibility, description, created_at, updated_at + FROM repositories WHERE name = ?`, name) + return scanRepo(row) +} + +// GetRepositoryByID returns a repository by id. +func (s *Store) GetRepositoryByID(ctx context.Context, id string) (*models.Repository, error) { + row := s.db.QueryRowContext(ctx, + `SELECT id, name, owner_user_id, visibility, description, created_at, updated_at + FROM repositories WHERE id = ?`, id) + return scanRepo(row) +} + +func scanRepo(row *sql.Row) (*models.Repository, error) { + r := &models.Repository{} + err := row.Scan(&r.ID, &r.Name, &r.OwnerUserID, &r.Visibility, &r.Description, &r.CreatedAt, &r.UpdatedAt) + if isErrNoRows(err) { + return nil, ErrNotFound + } + if err != nil { + return nil, err + } + return r, nil +} + +// GetMemberAccess returns the access level granted to userID on repoID, or +// ("", false) if the user is not an explicit member. +func (s *Store) GetMemberAccess(ctx context.Context, repoID, userID string) (models.Access, bool, error) { + row := s.db.QueryRowContext(ctx, + `SELECT access FROM repository_members WHERE repository_id = ? AND user_id = ?`, + repoID, userID) + var access string + err := row.Scan(&access) + if isErrNoRows(err) { + return "", false, nil + } + if err != nil { + return "", false, fmt.Errorf("get member access: %w", err) + } + return models.Access(access), true, nil +} diff --git a/server/store/repos_test.go b/server/store/repos_test.go new file mode 100644 index 0000000..2a3f276 --- /dev/null +++ b/server/store/repos_test.go @@ -0,0 +1,253 @@ +package store + +import ( + "context" + "errors" + "testing" + + "urapt/shared/models" +) + +func TestCreateRepository(t *testing.T) { + ctx := context.Background() + s := newTestStore(t) + u := s.createUser(t, ctx, "alice", "p") + + r := s.createRepo(t, ctx, "myrepo", u.ID, models.VisibilityPublic) + if r.Name != "myrepo" || r.OwnerUserID != u.ID || r.Visibility != models.VisibilityPublic { + t.Fatalf("repo = %+v", r) + } + if r.ID == "" || r.CreatedAt == "" { + t.Fatal("id/created_at should be set") + } +} + +func TestGetRepositoryByNameAndID(t *testing.T) { + ctx := context.Background() + s := newTestStore(t) + u := s.createUser(t, ctx, "alice", "p") + r := s.createRepo(t, ctx, "myrepo", u.ID, models.VisibilityPublic) + + byName, err := s.GetRepositoryByName(ctx, "myrepo") + if err != nil { + t.Fatalf("get by name: %v", err) + } + if byName.ID != r.ID { + t.Fatalf("id mismatch") + } + byID, err := s.GetRepositoryByID(ctx, r.ID) + if err != nil { + t.Fatalf("get by id: %v", err) + } + if byID.Name != "myrepo" { + t.Fatalf("name = %q", byID.Name) + } + if _, err := s.GetRepositoryByName(ctx, "missing"); !errors.Is(err, ErrNotFound) { + t.Fatalf("expected ErrNotFound, got %v", err) + } +} + +func TestListReposVisible(t *testing.T) { + ctx := context.Background() + s := newTestStore(t) + alice := s.createUser(t, ctx, "alice", "p") + bob := s.createUser(t, ctx, "bob", "p") + carol := s.createUser(t, ctx, "carol", "p") + + // alice owns: pub-own, priv-own + pubOwn := s.createRepo(t, ctx, "pub-own", alice.ID, models.VisibilityPublic) + privOwn := s.createRepo(t, ctx, "priv-own", alice.ID, models.VisibilityPrivate) + // bob owns: pub-bob, priv-bob, and grants carol read on priv-bob + pubBob := s.createRepo(t, ctx, "pub-bob", bob.ID, models.VisibilityPublic) + privBob := s.createRepo(t, ctx, "priv-bob", bob.ID, models.VisibilityPrivate) + _ = pubOwn + _ = privOwn + _ = pubBob + if err := s.AddMember(ctx, privBob.ID, carol.ID, models.AccessRead); err != nil { + t.Fatalf("add member: %v", err) + } + + // Alice sees: her two + bob's public. NOT bob's private. + aliceRepos, err := s.ListReposVisible(ctx, alice.ID) + if err != nil { + t.Fatalf("alice list: %v", err) + } + if len(aliceRepos) != 3 { + t.Fatalf("alice should see 3 repos, got %d", len(aliceRepos)) + } + + // Carol sees: pub-own, pub-bob (both public) + priv-bob (member). NOT priv-own. + carolRepos, err := s.ListReposVisible(ctx, carol.ID) + if err != nil { + t.Fatalf("carol list: %v", err) + } + if len(carolRepos) != 3 { + t.Fatalf("carol should see 3 repos, got %d", len(carolRepos)) + } + // Verify priv-bob is among carol's repos. + foundPrivBob := false + for _, r := range carolRepos { + if r.ID == privBob.ID { + foundPrivBob = true + } + if r.ID == privOwn.ID { + t.Fatal("carol should not see alice's private repo") + } + } + if !foundPrivBob { + t.Fatal("carol should see priv-bob as a member") + } +} + +func TestUpdateRepository(t *testing.T) { + ctx := context.Background() + s := newTestStore(t) + u := s.createUser(t, ctx, "alice", "p") + r := s.createRepo(t, ctx, "myrepo", u.ID, models.VisibilityPublic) + + // Update name only. + if err := s.UpdateRepository(ctx, r.ID, "newname", nil, nil); err != nil { + t.Fatalf("update name: %v", err) + } + got, _ := s.GetRepositoryByID(ctx, r.ID) + if got.Name != "newname" || got.Visibility != models.VisibilityPublic { + t.Fatalf("name=%q vis=%s", got.Name, got.Visibility) + } + + // Update visibility only. + priv := models.VisibilityPrivate + _ = s.UpdateRepository(ctx, r.ID, "", &priv, nil) + got, _ = s.GetRepositoryByID(ctx, r.ID) + if got.Visibility != models.VisibilityPrivate || got.Name != "newname" { + t.Fatalf("vis=%s name=%q", got.Visibility, got.Name) + } + + // Update description only. + desc := "a repo" + _ = s.UpdateRepository(ctx, r.ID, "", nil, &desc) + got, _ = s.GetRepositoryByID(ctx, r.ID) + if got.Description != "a repo" { + t.Fatalf("desc=%q", got.Description) + } +} + +func TestDeleteRepository(t *testing.T) { + ctx := context.Background() + s := newTestStore(t) + u := s.createUser(t, ctx, "alice", "p") + r := s.createRepo(t, ctx, "myrepo", u.ID, models.VisibilityPublic) + + if err := s.DeleteRepository(ctx, r.ID); err != nil { + t.Fatalf("delete: %v", err) + } + if _, err := s.GetRepositoryByID(ctx, r.ID); !errors.Is(err, ErrNotFound) { + t.Fatalf("expected ErrNotFound after delete, got %v", err) + } +} + +// --- members --- + +func TestAddMember_Upsert(t *testing.T) { + ctx := context.Background() + s := newTestStore(t) + alice := s.createUser(t, ctx, "alice", "p") + bob := s.createUser(t, ctx, "bob", "p") + repo := s.createRepo(t, ctx, "repo", alice.ID, models.VisibilityPrivate) + + // Initial grant: read. + if err := s.AddMember(ctx, repo.ID, bob.ID, models.AccessRead); err != nil { + t.Fatalf("add: %v", err) + } + access, ok, err := s.GetMemberAccess(ctx, repo.ID, bob.ID) + if err != nil { + t.Fatalf("get: %v", err) + } + if !ok || access != models.AccessRead { + t.Fatalf("expected read grant, got ok=%v access=%s", ok, access) + } + + // Upsert to write. + if err := s.AddMember(ctx, repo.ID, bob.ID, models.AccessWrite); err != nil { + t.Fatalf("upsert: %v", err) + } + access, ok, _ = s.GetMemberAccess(ctx, repo.ID, bob.ID) + if !ok || access != models.AccessWrite { + t.Fatalf("expected write after upsert, got %s", access) + } +} + +func TestGetMemberAccess_NonMember(t *testing.T) { + ctx := context.Background() + s := newTestStore(t) + alice := s.createUser(t, ctx, "alice", "p") + bob := s.createUser(t, ctx, "bob", "p") + repo := s.createRepo(t, ctx, "repo", alice.ID, models.VisibilityPrivate) + + _, ok, err := s.GetMemberAccess(ctx, repo.ID, bob.ID) + if err != nil { + t.Fatalf("get: %v", err) + } + if ok { + t.Fatal("non-member should return ok=false") + } +} + +func TestUpdateMemberAccess_NotFound(t *testing.T) { + ctx := context.Background() + s := newTestStore(t) + alice := s.createUser(t, ctx, "alice", "p") + bob := s.createUser(t, ctx, "bob", "p") + repo := s.createRepo(t, ctx, "repo", alice.ID, models.VisibilityPrivate) + + if err := s.UpdateMemberAccess(ctx, repo.ID, bob.ID, models.AccessRead); !errors.Is(err, ErrNotFound) { + t.Fatalf("update non-member should be ErrNotFound, got %v", err) + } +} + +func TestRemoveMember(t *testing.T) { + ctx := context.Background() + s := newTestStore(t) + alice := s.createUser(t, ctx, "alice", "p") + bob := s.createUser(t, ctx, "bob", "p") + repo := s.createRepo(t, ctx, "repo", alice.ID, models.VisibilityPrivate) + + _ = s.AddMember(ctx, repo.ID, bob.ID, models.AccessRead) + if err := s.RemoveMember(ctx, repo.ID, bob.ID); err != nil { + t.Fatalf("remove: %v", err) + } + _, ok, _ := s.GetMemberAccess(ctx, repo.ID, bob.ID) + if ok { + t.Fatal("member should be gone after remove") + } + // Removing again returns ErrNotFound. + if err := s.RemoveMember(ctx, repo.ID, bob.ID); !errors.Is(err, ErrNotFound) { + t.Fatalf("second remove should be ErrNotFound, got %v", err) + } +} + +func TestListMembers(t *testing.T) { + ctx := context.Background() + s := newTestStore(t) + alice := s.createUser(t, ctx, "alice", "p") + bob := s.createUser(t, ctx, "bob", "p") + carol := s.createUser(t, ctx, "carol", "p") + repo := s.createRepo(t, ctx, "repo", alice.ID, models.VisibilityPrivate) + + _ = s.AddMember(ctx, repo.ID, carol.ID, models.AccessRead) + _ = s.AddMember(ctx, repo.ID, bob.ID, models.AccessWrite) + + members, err := s.ListMembers(ctx, repo.ID) + if err != nil { + t.Fatalf("list: %v", err) + } + if len(members) != 2 { + t.Fatalf("expected 2 members, got %d", len(members)) + } + // Ordered by username: bob, carol. + if members[0].User.Username != "bob" || members[0].Access != models.AccessWrite { + t.Fatalf("member[0] = %+v", members[0]) + } + if members[1].User.Username != "carol" || members[1].Access != models.AccessRead { + t.Fatalf("member[1] = %+v", members[1]) + } +} diff --git a/server/store/store.go b/server/store/store.go new file mode 100644 index 0000000..031ea36 --- /dev/null +++ b/server/store/store.go @@ -0,0 +1,50 @@ +// Package store provides the data-access layer for urapt-server: typed +// methods over the SQLite database backing all domain objects (users, tokens, +// repositories, distributions, components, architectures, packages, blobs, +// gpg keys, audit log). +package store + +import ( + "context" + "database/sql" + "fmt" + "time" + + "github.com/google/uuid" +) + +// Store is the entrypoint to the data-access layer. All methods are safe for +// concurrent use; the underlying *sql.DB is configured with a single writer +// connection (see shared/db). +type Store struct { + db *sql.DB + now func() string +} + +// New constructs a Store wrapping db. +func New(db *sql.DB) *Store { + return &Store{db: db, now: nowISO} +} + +// DB returns the underlying database (used by app for raw queries if needed). +func (s *Store) DB() *sql.DB { return s.db } + +// Now returns the current timestamp in the urapt canonical form. +func (s *Store) Now() string { return s.now() } + +// newID returns a fresh UUIDv4 string. +func newID() string { return uuid.NewString() } + +// nowISO returns the current UTC time in RFC3339 form. +func nowISO() string { return time.Now().UTC().Format(time.RFC3339Nano) } + +// exec is a small helper for ExecContext with a context. +func (s *Store) exec(ctx context.Context, query string, args ...any) (sql.Result, error) { + return s.db.ExecContext(ctx, query, args...) +} + +// ErrNotFound is returned by Get-style methods when no row matches. +var ErrNotFound = fmt.Errorf("not found") + +// isErrNoRows returns true if err is sql.ErrNoRows. +func isErrNoRows(err error) bool { return err == sql.ErrNoRows } diff --git a/server/store/store_test.go b/server/store/store_test.go new file mode 100644 index 0000000..500507a --- /dev/null +++ b/server/store/store_test.go @@ -0,0 +1,87 @@ +package store + +import ( + "context" + "path/filepath" + "testing" + + "urapt/shared/crypto" + "urapt/shared/db" + "urapt/shared/models" +) + +// newTestStore opens a fresh migrated SQLite database in a per-test temp +// directory and returns a Store over it. The database is closed automatically +// when the test finishes. +func newTestStore(t *testing.T) *Store { + t.Helper() + dir := t.TempDir() + database, err := db.Open(filepath.Join(dir, "test.db")) + if err != nil { + t.Fatalf("db open: %v", err) + } + t.Cleanup(func() { database.Close() }) + return New(database) +} + +// createUser is a test helper that inserts a user with a bcrypt-hashed +// password and returns it. The password is hashed for realism so that +// password-verification paths can be exercised if needed. +func (s *Store) createUser(t *testing.T, ctx context.Context, username, password string) *models.User { + t.Helper() + hash, err := crypto.HashPassword(password) + if err != nil { + t.Fatalf("hash password: %v", err) + } + u, admin, err := s.CreateUser(ctx, username, hash) + if err != nil { + t.Fatalf("create user %q: %v", username, err) + } + t.Logf("created user %q (admin=%v)", u.Username, admin) + return u +} + +// createToken is a test helper that mints a real API token (with hash and +// prefix) for userID and returns the plaintext token plus the stored row. +func (s *Store) createToken(t *testing.T, ctx context.Context, userID, name string) (string, *models.APIToken) { + t.Helper() + tok, hash, prefix, err := crypto.GenerateToken() + if err != nil { + t.Fatalf("generate token: %v", err) + } + stored, err := s.CreateToken(ctx, userID, name, prefix, hash) + if err != nil { + t.Fatalf("create token: %v", err) + } + return tok, stored +} + +// createRepo is a test helper that creates a repository owned by userID. +func (s *Store) createRepo(t *testing.T, ctx context.Context, name, ownerID string, vis models.Visibility) *models.Repository { + t.Helper() + r, err := s.CreateRepository(ctx, name, ownerID, vis, "") + if err != nil { + t.Fatalf("create repo %q: %v", name, err) + } + return r +} + +// createDistro is a test helper that creates a distribution within repoID. +func (s *Store) createDistro(t *testing.T, ctx context.Context, repoID, name string) *models.Distribution { + t.Helper() + d, err := s.CreateDistribution(ctx, repoID, name) + if err != nil { + t.Fatalf("create distro %q: %v", name, err) + } + return d +} + +// createComponent is a test helper that creates a component within distroID. +func (s *Store) createComponent(t *testing.T, ctx context.Context, distroID, name string) *models.Component { + t.Helper() + c, err := s.CreateComponent(ctx, distroID, name) + if err != nil { + t.Fatalf("create component %q: %v", name, err) + } + return c +} diff --git a/server/store/structure.go b/server/store/structure.go new file mode 100644 index 0000000..01eaa95 --- /dev/null +++ b/server/store/structure.go @@ -0,0 +1,190 @@ +package store + +import ( + "context" + "database/sql" + "fmt" + + "urapt/shared/models" +) + +// --- distributions --- + +// CreateDistribution adds a distribution (suite) to a repository. +func (s *Store) CreateDistribution(ctx context.Context, repoID, name string) (*models.Distribution, error) { + id := newID() + now := s.now() + _, err := s.exec(ctx, `INSERT INTO distributions (id, repository_id, name, created_at) VALUES (?, ?, ?, ?)`, + id, repoID, name, now) + if err != nil { + return nil, fmt.Errorf("insert distribution: %w", err) + } + return &models.Distribution{ID: id, RepositoryID: repoID, Name: name, CreatedAt: now}, nil +} + +// GetDistributionByName returns a distribution by name within a repository. +func (s *Store) GetDistributionByName(ctx context.Context, repoID, name string) (*models.Distribution, error) { + row := s.db.QueryRowContext(ctx, + `SELECT id, repository_id, name, created_at FROM distributions WHERE repository_id = ? AND name = ?`, repoID, name) + d := &models.Distribution{} + err := row.Scan(&d.ID, &d.RepositoryID, &d.Name, &d.CreatedAt) + if isErrNoRows(err) { + return nil, ErrNotFound + } + return d, err +} + +// ListDistributions returns all distributions in a repository. +func (s *Store) ListDistributions(ctx context.Context, repoID string) ([]*models.Distribution, error) { + rows, err := s.db.QueryContext(ctx, + `SELECT id, repository_id, name, created_at FROM distributions WHERE repository_id = ? ORDER BY name`, repoID) + if err != nil { + return nil, fmt.Errorf("list distributions: %w", err) + } + defer rows.Close() + var out []*models.Distribution + for rows.Next() { + d := &models.Distribution{} + if err := rows.Scan(&d.ID, &d.RepositoryID, &d.Name, &d.CreatedAt); err != nil { + return nil, err + } + out = append(out, d) + } + return out, rows.Err() +} + +// DeleteDistribution removes a distribution and cascades to components, +// architectures, and packages. +func (s *Store) DeleteDistribution(ctx context.Context, repoID, name string) error { + res, err := s.exec(ctx, `DELETE FROM distributions WHERE repository_id = ? AND name = ?`, repoID, name) + if err != nil { + return fmt.Errorf("delete distribution: %w", err) + } + n, _ := res.RowsAffected() + if n == 0 { + return ErrNotFound + } + return nil +} + +// --- components --- + +// CreateComponent adds a component to a distribution. +func (s *Store) CreateComponent(ctx context.Context, distroID, name string) (*models.Component, error) { + id := newID() + now := s.now() + _, err := s.exec(ctx, `INSERT INTO components (id, distribution_id, name, created_at) VALUES (?, ?, ?, ?)`, + id, distroID, name, now) + if err != nil { + return nil, fmt.Errorf("insert component: %w", err) + } + return &models.Component{ID: id, DistributionID: distroID, Name: name, CreatedAt: now}, nil +} + +// GetComponentByName returns a component by name within a distribution. +func (s *Store) GetComponentByName(ctx context.Context, distroID, name string) (*models.Component, error) { + row := s.db.QueryRowContext(ctx, + `SELECT id, distribution_id, name, created_at FROM components WHERE distribution_id = ? AND name = ?`, distroID, name) + c := &models.Component{} + err := row.Scan(&c.ID, &c.DistributionID, &c.Name, &c.CreatedAt) + if isErrNoRows(err) { + return nil, ErrNotFound + } + return c, err +} + +// ListComponents returns all components in a distribution. +func (s *Store) ListComponents(ctx context.Context, distroID string) ([]*models.Component, error) { + rows, err := s.db.QueryContext(ctx, + `SELECT id, distribution_id, name, created_at FROM components WHERE distribution_id = ? ORDER BY name`, distroID) + if err != nil { + return nil, fmt.Errorf("list components: %w", err) + } + defer rows.Close() + var out []*models.Component + for rows.Next() { + c := &models.Component{} + if err := rows.Scan(&c.ID, &c.DistributionID, &c.Name, &c.CreatedAt); err != nil { + return nil, err + } + out = append(out, c) + } + return out, rows.Err() +} + +// DeleteComponent removes a component. The schema blocks deletion while +// packages reference it (ON DELETE RESTRICT); the handler checks first. +func (s *Store) DeleteComponent(ctx context.Context, distroID, name string) error { + res, err := s.exec(ctx, `DELETE FROM components WHERE distribution_id = ? AND name = ?`, distroID, name) + if err != nil { + return fmt.Errorf("delete component: %w", err) + } + n, _ := res.RowsAffected() + if n == 0 { + return ErrNotFound + } + return nil +} + +// CountComponentsByDistro reports how many components a distribution has. +func (s *Store) CountComponentsByDistro(ctx context.Context, distroID string) (int, error) { + var n int + err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM components WHERE distribution_id = ?`, distroID).Scan(&n) + return n, err +} + +// --- architectures --- + +// CreateArchitecture adds an architecture to a distribution. +func (s *Store) CreateArchitecture(ctx context.Context, distroID, name string) (*models.Architecture, error) { + id := newID() + now := s.now() + _, err := s.exec(ctx, `INSERT INTO architectures (id, distribution_id, name, created_at) VALUES (?, ?, ?, ?)`, + id, distroID, name, now) + if err != nil { + return nil, fmt.Errorf("insert architecture: %w", err) + } + return &models.Architecture{ID: id, DistributionID: distroID, Name: name, CreatedAt: now}, nil +} + +// ListArchitectures returns all architectures in a distribution. +func (s *Store) ListArchitectures(ctx context.Context, distroID string) ([]*models.Architecture, error) { + rows, err := s.db.QueryContext(ctx, + `SELECT id, distribution_id, name, created_at FROM architectures WHERE distribution_id = ? ORDER BY name`, distroID) + if err != nil { + return nil, fmt.Errorf("list architectures: %w", err) + } + defer rows.Close() + var out []*models.Architecture + for rows.Next() { + a := &models.Architecture{} + if err := rows.Scan(&a.ID, &a.DistributionID, &a.Name, &a.CreatedAt); err != nil { + return nil, err + } + out = append(out, a) + } + return out, rows.Err() +} + +// DeleteArchitecture removes an architecture from a distribution. +func (s *Store) DeleteArchitecture(ctx context.Context, distroID, name string) error { + res, err := s.exec(ctx, `DELETE FROM architectures WHERE distribution_id = ? AND name = ?`, distroID, name) + if err != nil { + return fmt.Errorf("delete architecture: %w", err) + } + n, _ := res.RowsAffected() + if n == 0 { + return ErrNotFound + } + return nil +} + +// HasArchitecture reports whether a distribution declares the given arch. +func (s *Store) HasArchitecture(ctx context.Context, distroID, name string) (bool, error) { + var n int + err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM architectures WHERE distribution_id = ? AND name = ?`, distroID, name).Scan(&n) + if err == sql.ErrNoRows { + return false, nil + } + return n > 0, err +} diff --git a/server/store/structure_test.go b/server/store/structure_test.go new file mode 100644 index 0000000..64e60b8 --- /dev/null +++ b/server/store/structure_test.go @@ -0,0 +1,204 @@ +package store + +import ( + "context" + "errors" + "testing" +) + +// --- distributions --- + +func TestCreateDistributionAndGetByName(t *testing.T) { + ctx := context.Background() + s := newTestStore(t) + u := s.createUser(t, ctx, "alice", "p") + repo := s.createRepo(t, ctx, "repo", u.ID, "public") + + d := s.createDistro(t, ctx, repo.ID, "stable") + if d.Name != "stable" || d.RepositoryID != repo.ID { + t.Fatalf("distro = %+v", d) + } + + got, err := s.GetDistributionByName(ctx, repo.ID, "stable") + if err != nil { + t.Fatalf("get: %v", err) + } + if got.ID != d.ID { + t.Fatal("id mismatch") + } + if _, err := s.GetDistributionByName(ctx, repo.ID, "missing"); !errors.Is(err, ErrNotFound) { + t.Fatalf("expected ErrNotFound, got %v", err) + } +} + +func TestListDistributions(t *testing.T) { + ctx := context.Background() + s := newTestStore(t) + u := s.createUser(t, ctx, "alice", "p") + repo := s.createRepo(t, ctx, "repo", u.ID, "public") + s.createDistro(t, ctx, repo.ID, "stable") + s.createDistro(t, ctx, repo.ID, "unstable") + s.createDistro(t, ctx, repo.ID, "oldstable") + + ds, err := s.ListDistributions(ctx, repo.ID) + if err != nil { + t.Fatalf("list: %v", err) + } + if len(ds) != 3 { + t.Fatalf("expected 3 distros, got %d", len(ds)) + } + // Ordered by name. + if ds[0].Name != "oldstable" || ds[1].Name != "stable" || ds[2].Name != "unstable" { + names := []string{ds[0].Name, ds[1].Name, ds[2].Name} + t.Fatalf("expected sorted, got %v", names) + } +} + +func TestDeleteDistribution(t *testing.T) { + ctx := context.Background() + s := newTestStore(t) + u := s.createUser(t, ctx, "alice", "p") + repo := s.createRepo(t, ctx, "repo", u.ID, "public") + s.createDistro(t, ctx, repo.ID, "stable") + + if err := s.DeleteDistribution(ctx, repo.ID, "stable"); err != nil { + t.Fatalf("delete: %v", err) + } + if _, err := s.GetDistributionByName(ctx, repo.ID, "stable"); !errors.Is(err, ErrNotFound) { + t.Fatalf("expected ErrNotFound after delete, got %v", err) + } + if err := s.DeleteDistribution(ctx, repo.ID, "stable"); !errors.Is(err, ErrNotFound) { + t.Fatalf("second delete should be ErrNotFound, got %v", err) + } +} + +// --- components --- + +func TestCreateComponentAndList(t *testing.T) { + ctx := context.Background() + s := newTestStore(t) + u := s.createUser(t, ctx, "alice", "p") + repo := s.createRepo(t, ctx, "repo", u.ID, "public") + d := s.createDistro(t, ctx, repo.ID, "stable") + + s.createComponent(t, ctx, d.ID, "main") + s.createComponent(t, ctx, d.ID, "contrib") + s.createComponent(t, ctx, d.ID, "non-free") + + cs, err := s.ListComponents(ctx, d.ID) + if err != nil { + t.Fatalf("list: %v", err) + } + if len(cs) != 3 { + t.Fatalf("expected 3 components, got %d", len(cs)) + } + if cs[0].Name != "contrib" { + t.Fatalf("expected sorted; first = %q", cs[0].Name) + } +} + +func TestDeleteComponent(t *testing.T) { + ctx := context.Background() + s := newTestStore(t) + u := s.createUser(t, ctx, "alice", "p") + repo := s.createRepo(t, ctx, "repo", u.ID, "public") + d := s.createDistro(t, ctx, repo.ID, "stable") + s.createComponent(t, ctx, d.ID, "main") + + if err := s.DeleteComponent(ctx, d.ID, "main"); err != nil { + t.Fatalf("delete: %v", err) + } + if err := s.DeleteComponent(ctx, d.ID, "main"); !errors.Is(err, ErrNotFound) { + t.Fatalf("second delete should be ErrNotFound, got %v", err) + } +} + +func TestCountComponentsByDistro(t *testing.T) { + ctx := context.Background() + s := newTestStore(t) + u := s.createUser(t, ctx, "alice", "p") + repo := s.createRepo(t, ctx, "repo", u.ID, "public") + d := s.createDistro(t, ctx, repo.ID, "stable") + + n, err := s.CountComponentsByDistro(ctx, d.ID) + if err != nil { + t.Fatalf("count: %v", err) + } + if n != 0 { + t.Fatalf("expected 0, got %d", n) + } + s.createComponent(t, ctx, d.ID, "main") + s.createComponent(t, ctx, d.ID, "contrib") + n, _ = s.CountComponentsByDistro(ctx, d.ID) + if n != 2 { + t.Fatalf("expected 2, got %d", n) + } +} + +// --- architectures --- + +func TestCreateArchitectureAndList(t *testing.T) { + ctx := context.Background() + s := newTestStore(t) + u := s.createUser(t, ctx, "alice", "p") + repo := s.createRepo(t, ctx, "repo", u.ID, "public") + d := s.createDistro(t, ctx, repo.ID, "stable") + + if _, err := s.CreateArchitecture(ctx, d.ID, "amd64"); err != nil { + t.Fatalf("create amd64: %v", err) + } + if _, err := s.CreateArchitecture(ctx, d.ID, "arm64"); err != nil { + t.Fatalf("create arm64: %v", err) + } + + arches, err := s.ListArchitectures(ctx, d.ID) + if err != nil { + t.Fatalf("list: %v", err) + } + if len(arches) != 2 { + t.Fatalf("expected 2 arches, got %d", len(arches)) + } + if arches[0].Name != "amd64" || arches[1].Name != "arm64" { + t.Fatalf("expected sorted, got %q %q", arches[0].Name, arches[1].Name) + } +} + +func TestDeleteArchitecture(t *testing.T) { + ctx := context.Background() + s := newTestStore(t) + u := s.createUser(t, ctx, "alice", "p") + repo := s.createRepo(t, ctx, "repo", u.ID, "public") + d := s.createDistro(t, ctx, repo.ID, "stable") + _, _ = s.CreateArchitecture(ctx, d.ID, "amd64") + + if err := s.DeleteArchitecture(ctx, d.ID, "amd64"); err != nil { + t.Fatalf("delete: %v", err) + } + if err := s.DeleteArchitecture(ctx, d.ID, "amd64"); !errors.Is(err, ErrNotFound) { + t.Fatalf("second delete should be ErrNotFound, got %v", err) + } +} + +func TestHasArchitecture(t *testing.T) { + ctx := context.Background() + s := newTestStore(t) + u := s.createUser(t, ctx, "alice", "p") + repo := s.createRepo(t, ctx, "repo", u.ID, "public") + d := s.createDistro(t, ctx, repo.ID, "stable") + _, _ = s.CreateArchitecture(ctx, d.ID, "amd64") + + has, err := s.HasArchitecture(ctx, d.ID, "amd64") + if err != nil { + t.Fatalf("has amd64: %v", err) + } + if !has { + t.Fatal("expected has=true for amd64") + } + has, err = s.HasArchitecture(ctx, d.ID, "arm64") + if err != nil { + t.Fatalf("has arm64: %v", err) + } + if has { + t.Fatal("expected has=false for arm64") + } +} diff --git a/server/store/tokens.go b/server/store/tokens.go new file mode 100644 index 0000000..50d7b88 --- /dev/null +++ b/server/store/tokens.go @@ -0,0 +1,105 @@ +package store + +import ( + "context" + "database/sql" + "fmt" + + "urapt/shared/models" +) + +// CreateToken inserts a new API token row. The plaintext token is NOT stored; +// only its SHA-256 hash and display prefix are. +func (s *Store) CreateToken(ctx context.Context, userID, name, prefix, tokenHash string) (*models.APIToken, error) { + id := newID() + now := s.now() + _, err := s.exec(ctx, `INSERT INTO api_tokens (id, user_id, name, prefix, token_hash, created_at) + VALUES (?, ?, ?, ?, ?, ?)`, id, userID, name, prefix, tokenHash, now) + if err != nil { + return nil, fmt.Errorf("insert token: %w", err) + } + return &models.APIToken{ + ID: id, UserID: userID, Name: name, Prefix: prefix, CreatedAt: now, + }, nil +} + +// GetTokenByHash returns the active (non-revoked) token with the given hash. +func (s *Store) GetTokenByHash(ctx context.Context, hash string) (*models.APIToken, error) { + row := s.db.QueryRowContext(ctx, + `SELECT id, user_id, name, prefix, created_at, last_used_at, revoked_at + FROM api_tokens WHERE token_hash = ? AND revoked_at IS NULL`, hash) + t := &models.APIToken{} + var lastUsed, revoked sql.NullString + err := row.Scan(&t.ID, &t.UserID, &t.Name, &t.Prefix, &t.CreatedAt, &lastUsed, &revoked) + if isErrNoRows(err) { + return nil, ErrNotFound + } + if err != nil { + return nil, err + } + if lastUsed.Valid { + v := lastUsed.String + t.LastUsedAt = &v + } + if revoked.Valid { + v := revoked.String + t.RevokedAt = &v + } + return t, nil +} + +// TouchToken updates last_used_at for a token. +func (s *Store) TouchToken(ctx context.Context, id string) error { + _, err := s.exec(ctx, `UPDATE api_tokens SET last_used_at = ? WHERE id = ?`, s.now(), id) + return err +} + +// ListTokens returns all tokens for a user (including revoked). +func (s *Store) ListTokens(ctx context.Context, userID string) ([]*models.APIToken, error) { + rows, err := s.db.QueryContext(ctx, + `SELECT id, user_id, name, prefix, created_at, last_used_at, revoked_at + FROM api_tokens WHERE user_id = ? ORDER BY created_at`, userID) + if err != nil { + return nil, fmt.Errorf("list tokens: %w", err) + } + defer rows.Close() + var out []*models.APIToken + for rows.Next() { + t := &models.APIToken{} + var lastUsed, revoked sql.NullString + if err := rows.Scan(&t.ID, &t.UserID, &t.Name, &t.Prefix, &t.CreatedAt, &lastUsed, &revoked); err != nil { + return nil, err + } + if lastUsed.Valid { + v := lastUsed.String + t.LastUsedAt = &v + } + if revoked.Valid { + v := revoked.String + t.RevokedAt = &v + } + out = append(out, t) + } + return out, rows.Err() +} + +// RevokeToken marks the given token revoked. It must belong to userID. +func (s *Store) RevokeToken(ctx context.Context, userID, tokenID string) error { + res, err := s.exec(ctx, `UPDATE api_tokens SET revoked_at = ? WHERE id = ? AND user_id = ?`, + s.now(), tokenID, userID) + if err != nil { + return fmt.Errorf("revoke token: %w", err) + } + n, _ := res.RowsAffected() + if n == 0 { + return ErrNotFound + } + return nil +} + +// RevokeTokenByHash marks the token with the given hash revoked (used for logout). +func (s *Store) RevokeTokenByHash(ctx context.Context, hash string) error { + _, err := s.exec(ctx, `UPDATE api_tokens SET revoked_at = ? WHERE token_hash = ? AND revoked_at IS NULL`, + s.now(), hash) + return err +} diff --git a/server/store/users.go b/server/store/users.go new file mode 100644 index 0000000..3a5b1f0 --- /dev/null +++ b/server/store/users.go @@ -0,0 +1,127 @@ +package store + +import ( + "context" + "database/sql" + "fmt" + "strings" + + "urapt/shared/models" +) + +// CreateUser inserts a new user. If the users table is empty, the user is made +// an admin (the bootstrap-admin rule). +func (s *Store) CreateUser(ctx context.Context, username, passwordHash string) (*models.User, bool, error) { + username = strings.TrimSpace(username) + lc := strings.ToLower(username) + id := newID() + now := s.now() + + var admin bool + var count int + if err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM users`).Scan(&count); err != nil { + return nil, false, fmt.Errorf("count users: %w", err) + } + admin = count == 0 + + _, err := s.exec(ctx, `INSERT INTO users (id, username, username_lc, password_hash, is_admin, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + id, username, lc, passwordHash, boolToInt(admin), now, now) + if err != nil { + return nil, false, fmt.Errorf("insert user: %w", err) + } + return &models.User{ + ID: id, Username: username, IsAdmin: admin, CreatedAt: now, UpdatedAt: now, + }, admin, nil +} + +// GetUserByID returns the user with the given id. +func (s *Store) GetUserByID(ctx context.Context, id string) (*models.User, error) { + row := s.db.QueryRowContext(ctx, + `SELECT id, username, is_admin, created_at, updated_at FROM users WHERE id = ?`, id) + return scanUser(row) +} + +// GetUserByUsername returns the user with the given (case-insensitive) username. +func (s *Store) GetUserByUsername(ctx context.Context, username string) (*models.User, error) { + row := s.db.QueryRowContext(ctx, + `SELECT id, username, is_admin, created_at, updated_at FROM users WHERE username_lc = ?`, + strings.ToLower(strings.TrimSpace(username))) + return scanUser(row) +} + +// ListUsers returns all users ordered by username. +func (s *Store) ListUsers(ctx context.Context) ([]*models.User, error) { + rows, err := s.db.QueryContext(ctx, + `SELECT id, username, is_admin, created_at, updated_at FROM users ORDER BY username_lc`) + if err != nil { + return nil, fmt.Errorf("list users: %w", err) + } + defer rows.Close() + var out []*models.User + for rows.Next() { + u, err := scanUserRows(rows) + if err != nil { + return nil, err + } + out = append(out, u) + } + return out, rows.Err() +} + +// GetUserPasswordHash returns the stored bcrypt hash for a user. +func (s *Store) GetUserPasswordHash(ctx context.Context, id string) (string, error) { + var hash string + err := s.db.QueryRowContext(ctx, `SELECT password_hash FROM users WHERE id = ?`, id).Scan(&hash) + if isErrNoRows(err) { + return "", ErrNotFound + } + return hash, err +} + +// UpdateUser updates mutable fields. If isAdmin is nil, it is left unchanged. +func (s *Store) UpdateUser(ctx context.Context, id string, isAdmin *bool) error { + now := s.now() + if isAdmin != nil { + if _, err := s.exec(ctx, `UPDATE users SET is_admin = ?, updated_at = ? WHERE id = ?`, + boolToInt(*isAdmin), now, id); err != nil { + return fmt.Errorf("update user: %w", err) + } + } + return nil +} + +// DeleteUser removes a user. The caller should prevent self-deletion. +func (s *Store) DeleteUser(ctx context.Context, id string) error { + if _, err := s.exec(ctx, `DELETE FROM users WHERE id = ?`, id); err != nil { + return fmt.Errorf("delete user: %w", err) + } + return nil +} + +func scanUser(row *sql.Row) (*models.User, error) { + u := &models.User{} + err := row.Scan(&u.ID, &u.Username, &u.IsAdmin, &u.CreatedAt, &u.UpdatedAt) + if isErrNoRows(err) { + return nil, ErrNotFound + } + if err != nil { + return nil, err + } + return u, nil +} + +func scanUserRows(rows *sql.Rows) (*models.User, error) { + u := &models.User{} + if err := rows.Scan(&u.ID, &u.Username, &u.IsAdmin, &u.CreatedAt, &u.UpdatedAt); err != nil { + return nil, err + } + return u, nil +} + +func boolToInt(b bool) int { + if b { + return 1 + } + return 0 +} diff --git a/server/store/users_test.go b/server/store/users_test.go new file mode 100644 index 0000000..7421d31 --- /dev/null +++ b/server/store/users_test.go @@ -0,0 +1,299 @@ +package store + +import ( + "context" + "errors" + "testing" + + "urapt/shared/crypto" +) + +func TestCreateUser_BootstrapAdmin(t *testing.T) { + ctx := context.Background() + s := newTestStore(t) + + // First user becomes admin. + u1 := s.createUser(t, ctx, "alice", "pw1") + if !u1.IsAdmin { + t.Fatalf("first user should be admin, got is_admin=%v", u1.IsAdmin) + } + + // Subsequent users are not admin. + u2 := s.createUser(t, ctx, "bob", "pw2") + if u2.IsAdmin { + t.Fatalf("second user should not be admin") + } + u3 := s.createUser(t, ctx, "carol", "pw3") + if u3.IsAdmin { + t.Fatalf("third user should not be admin") + } +} + +func TestCreateUser_DuplicateUsernameRejected(t *testing.T) { + ctx := context.Background() + s := newTestStore(t) + s.createUser(t, ctx, "alice", "pw1") + if _, _, err := s.CreateUser(ctx, "alice", "hash"); err == nil { + t.Fatal("expected error creating duplicate username") + } +} + +func TestGetUserByID(t *testing.T) { + ctx := context.Background() + s := newTestStore(t) + u := s.createUser(t, ctx, "alice", "pw1") + + got, err := s.GetUserByID(ctx, u.ID) + if err != nil { + t.Fatalf("get by id: %v", err) + } + if got.ID != u.ID || got.Username != "alice" { + t.Fatalf("got %+v", got) + } + + if _, err := s.GetUserByID(ctx, "nope"); !errors.Is(err, ErrNotFound) { + t.Fatalf("expected ErrNotFound, got %v", err) + } +} + +func TestGetUserByUsername_CaseInsensitive(t *testing.T) { + ctx := context.Background() + s := newTestStore(t) + s.createUser(t, ctx, "Alice", "pw") + + for _, q := range []string{"alice", "ALICE", "AlIcE"} { + got, err := s.GetUserByUsername(ctx, q) + if err != nil { + t.Fatalf("lookup %q: %v", q, err) + } + if got.Username != "Alice" { + t.Fatalf("expected original casing 'Alice', got %q", got.Username) + } + } + if _, err := s.GetUserByUsername(ctx, "bob"); !errors.Is(err, ErrNotFound) { + t.Fatalf("expected ErrNotFound for missing user, got %v", err) + } +} + +func TestGetUserPasswordHash(t *testing.T) { + ctx := context.Background() + s := newTestStore(t) + u := s.createUser(t, ctx, "alice", "supersecret") + + hash, err := s.GetUserPasswordHash(ctx, u.ID) + if err != nil { + t.Fatalf("get hash: %v", err) + } + if !crypto.VerifyPassword(hash, "supersecret") { + t.Fatal("bcrypt hash did not verify against original password") + } + if crypto.VerifyPassword(hash, "wrong") { + t.Fatal("bcrypt hash verified against wrong password") + } +} + +func TestListUsers(t *testing.T) { + ctx := context.Background() + s := newTestStore(t) + s.createUser(t, ctx, "carol", "p") + s.createUser(t, ctx, "alice", "p") + s.createUser(t, ctx, "bob", "p") + + users, err := s.ListUsers(ctx) + if err != nil { + t.Fatalf("list: %v", err) + } + if len(users) != 3 { + t.Fatalf("expected 3 users, got %d", len(users)) + } + // Ordered by username_lc. + if users[0].Username != "alice" || users[1].Username != "bob" || users[2].Username != "carol" { + names := []string{users[0].Username, users[1].Username, users[2].Username} + t.Fatalf("expected alphabetical order, got %v", names) + } +} + +func TestUpdateUser(t *testing.T) { + ctx := context.Background() + s := newTestStore(t) + // First user is bootstrap admin; create a second non-admin user to test + // promotion/demotion on. + s.createUser(t, ctx, "admin", "p") + u := s.createUser(t, ctx, "alice", "p") + if u.IsAdmin { + t.Fatal("non-bootstrap user should not be admin") + } + + admin := true + if err := s.UpdateUser(ctx, u.ID, &admin); err != nil { + t.Fatalf("update: %v", err) + } + got, _ := s.GetUserByID(ctx, u.ID) + if !got.IsAdmin { + t.Fatal("expected is_admin=true after update") + } + + off := false + _ = s.UpdateUser(ctx, u.ID, &off) + got, _ = s.GetUserByID(ctx, u.ID) + if got.IsAdmin { + t.Fatal("expected is_admin=false after demotion") + } + + // nil leaves it unchanged. + _ = s.UpdateUser(ctx, u.ID, nil) + got, _ = s.GetUserByID(ctx, u.ID) + if got.IsAdmin { + t.Fatal("nil isAdmin should leave it false") + } +} + +func TestDeleteUser(t *testing.T) { + ctx := context.Background() + s := newTestStore(t) + u := s.createUser(t, ctx, "alice", "p") + + if err := s.DeleteUser(ctx, u.ID); err != nil { + t.Fatalf("delete: %v", err) + } + if _, err := s.GetUserByID(ctx, u.ID); !errors.Is(err, ErrNotFound) { + t.Fatalf("expected ErrNotFound after delete, got %v", err) + } +} + +// --- tokens --- + +func TestCreateTokenAndGetByHash(t *testing.T) { + ctx := context.Background() + s := newTestStore(t) + u := s.createUser(t, ctx, "alice", "p") + + plaintext, stored := s.createToken(t, ctx, u.ID, "laptop") + if stored.Name != "laptop" { + t.Fatalf("name = %q", stored.Name) + } + if stored.Prefix == "" { + t.Fatal("prefix should be set") + } + + got, err := s.GetTokenByHash(ctx, crypto.HashToken(plaintext)) + if err != nil { + t.Fatalf("get by hash: %v", err) + } + if got.ID != stored.ID { + t.Fatalf("id mismatch: %s vs %s", got.ID, stored.ID) + } + if got.RevokedAt != nil { + t.Fatal("fresh token should not be revoked") + } +} + +func TestGetTokenByHash_RevokedExcluded(t *testing.T) { + ctx := context.Background() + s := newTestStore(t) + u := s.createUser(t, ctx, "alice", "p") + plaintext, stored := s.createToken(t, ctx, u.ID, "laptop") + + if err := s.RevokeToken(ctx, u.ID, stored.ID); err != nil { + t.Fatalf("revoke: %v", err) + } + if _, err := s.GetTokenByHash(ctx, crypto.HashToken(plaintext)); !errors.Is(err, ErrNotFound) { + t.Fatalf("revoked token should not be returned, got %v", err) + } +} + +func TestRevokeToken_OwnershipEnforced(t *testing.T) { + ctx := context.Background() + s := newTestStore(t) + alice := s.createUser(t, ctx, "alice", "p") + bob := s.createUser(t, ctx, "bob", "p") + _, aliceToken := s.createToken(t, ctx, alice.ID, "alice-laptop") + + // Bob cannot revoke Alice's token. + if err := s.RevokeToken(ctx, bob.ID, aliceToken.ID); !errors.Is(err, ErrNotFound) { + t.Fatalf("cross-user revoke should be ErrNotFound, got %v", err) + } + // Alice can revoke her own. + if err := s.RevokeToken(ctx, alice.ID, aliceToken.ID); err != nil { + t.Fatalf("own revoke: %v", err) + } +} + +func TestRevokeTokenByHash(t *testing.T) { + ctx := context.Background() + s := newTestStore(t) + u := s.createUser(t, ctx, "alice", "p") + plaintext, _ := s.createToken(t, ctx, u.ID, "laptop") + + if err := s.RevokeTokenByHash(ctx, crypto.HashToken(plaintext)); err != nil { + t.Fatalf("revoke by hash: %v", err) + } + if _, err := s.GetTokenByHash(ctx, crypto.HashToken(plaintext)); !errors.Is(err, ErrNotFound) { + t.Fatalf("revoked token should not be found, got %v", err) + } + // Revoking again is a no-op (no error, no rows affected). + if err := s.RevokeTokenByHash(ctx, crypto.HashToken(plaintext)); err != nil { + t.Fatalf("idempotent revoke: %v", err) + } +} + +func TestListTokens_IncludesRevoked(t *testing.T) { + ctx := context.Background() + s := newTestStore(t) + u := s.createUser(t, ctx, "alice", "p") + _, t1 := s.createToken(t, ctx, u.ID, "a") + _, t2 := s.createToken(t, ctx, u.ID, "b") + _ = s.RevokeToken(ctx, u.ID, t1.ID) + + list, err := s.ListTokens(ctx, u.ID) + if err != nil { + t.Fatalf("list: %v", err) + } + if len(list) != 2 { + t.Fatalf("expected 2 tokens (incl revoked), got %d", len(list)) + } + for _, tk := range list { + if tk.ID == t1.ID && tk.RevokedAt == nil { + t.Fatal("revoked token should have RevokedAt set") + } + if tk.ID == t2.ID && tk.RevokedAt != nil { + t.Fatal("active token should not be revoked") + } + } +} + +func TestListTokens_ScopedToUser(t *testing.T) { + ctx := context.Background() + s := newTestStore(t) + alice := s.createUser(t, ctx, "alice", "p") + bob := s.createUser(t, ctx, "bob", "p") + s.createToken(t, ctx, alice.ID, "alice-token") + s.createToken(t, ctx, bob.ID, "bob-token") + + aliceList, _ := s.ListTokens(ctx, alice.ID) + if len(aliceList) != 1 || aliceList[0].UserID != alice.ID { + t.Fatalf("alice should see only her token, got %d", len(aliceList)) + } +} + +func TestTouchToken(t *testing.T) { + ctx := context.Background() + s := newTestStore(t) + u := s.createUser(t, ctx, "alice", "p") + plaintext, stored := s.createToken(t, ctx, u.ID, "laptop") + + if stored.LastUsedAt != nil { + t.Fatal("fresh token should have nil LastUsedAt") + } + if err := s.TouchToken(ctx, stored.ID); err != nil { + t.Fatalf("touch: %v", err) + } + // Look up via the hash to confirm last_used_at was written. + got, err := s.GetTokenByHash(ctx, crypto.HashToken(plaintext)) + if err != nil { + t.Fatalf("get by hash: %v", err) + } + if got.LastUsedAt == nil { + t.Fatal("expected LastUsedAt set after touch") + } +} diff --git a/shared/api/api.go b/shared/api/api.go new file mode 100644 index 0000000..e01e8ba --- /dev/null +++ b/shared/api/api.go @@ -0,0 +1,90 @@ +// Package api defines the request and response DTOs that form the urapt REST +// API contract. The server's restapi package produces these and the CLI's +// apiclient package consumes them; keeping them in one place prevents drift. +package api + +import "urapt/shared/models" + +// --- server / setup --- + +// ServerInfo is the response from GET /server/info. +type ServerInfo struct { + Version string `json:"version"` + NeedsSetup bool `json:"needs_setup"` + DefaultKeyFingerprint string `json:"default_key_fingerprint"` + OpenRegistration bool `json:"open_registration"` +} + +// --- auth --- + +// RegisterRequest is the body for POST /auth/register. +type RegisterRequest struct { + Username string `json:"username" validate:"required,min=3,max=32,username"` + Password string `json:"password" validate:"required,min=8,max=256"` +} + +// LoginRequest is the body for POST /auth/login. +type LoginRequest struct { + Username string `json:"username" validate:"required"` + Password string `json:"password" validate:"required"` +} + +// AuthResponse is returned by register and login. +type AuthResponse struct { + User *models.User `json:"user"` + Token string `json:"token"` +} + +// CreateTokenRequest is the body for POST /me/tokens. +type CreateTokenRequest struct { + Name string `json:"name" validate:"required,min=1,max=64"` +} + +// ListResponse wraps a page of items. +type ListResponse[T any] struct { + Items []T `json:"items"` + Page int `json:"page"` + PerPage int `json:"per_page"` + Total int `json:"total"` +} + +// --- users (admin) --- + +// UpdateUserRequest is the body for PATCH /users/:id. +type UpdateUserRequest struct { + IsAdmin *bool `json:"is_admin,omitempty"` +} + +// --- repositories --- + +// CreateRepoRequest is the body for POST /repositories. +type CreateRepoRequest struct { + Name string `json:"name"` + Visibility string `json:"visibility"` + Description string `json:"description,omitempty"` +} + +// UpdateRepoRequest is the body for PATCH /repositories/:repo. +type UpdateRepoRequest struct { + Name *string `json:"name,omitempty"` + Visibility *string `json:"visibility,omitempty"` + Description *string `json:"description,omitempty"` +} + +// AddMemberRequest is the body for POST /repositories/:repo/members. +type AddMemberRequest struct { + Username string `json:"username"` + Access string `json:"access"` +} + +// UpdateMemberRequest is the body for PATCH /repositories/:repo/members/:username. +type UpdateMemberRequest struct { + Access string `json:"access"` +} + +// --- structure --- + +// CreateNamedRequest is the body for creating a distribution/component/arch. +type CreateNamedRequest struct { + Name string `json:"name"` +} diff --git a/shared/apiclient/apiclient_test.go b/shared/apiclient/apiclient_test.go new file mode 100644 index 0000000..520c1ec --- /dev/null +++ b/shared/apiclient/apiclient_test.go @@ -0,0 +1,145 @@ +package apiclient_test + +import ( + "bytes" + "os" + "path/filepath" + "testing" + + "github.com/go-chi/chi/v5" + + "urapt/server/aptrepo" + "urapt/server/auth" + "urapt/server/cache" + "urapt/server/restapi" + "urapt/server/store" + "urapt/shared/apiclient" + "urapt/shared/config" + "urapt/shared/db" + "urapt/shared/gpg" +) + +func newServer(t *testing.T) (baseURL string, cleanup func()) { + t.Helper() + dir := t.TempDir() + database, err := db.Open(filepath.Join(dir, "test.db")) + if err != nil { + t.Fatalf("db open: %v", err) + } + st := store.New(database) + authSvc := auth.NewService(st) + key, err := gpg.GenerateKey("urapt-test ", 2048) + if err != nil { + t.Fatalf("gpg: %v", err) + } + sp := &signer{key: key} + cfg := config.Defaults + cfg.StoreDir = dir + cfg.PackagesDir = filepath.Join(dir, "packages") + cfg.DBPath = filepath.Join(dir, "test.db") + _ = os.MkdirAll(cfg.PackagesDir, 0o755) + + idxCache := cache.New() + root := chi.NewRouter() + root.Mount("/api/v1", restapi.New(st, authSvc, sp, &cfg, idxCache)) + root.Mount("/apt", aptrepo.New(st, authSvc, key, idxCache)) + srv := newHTTPServer(root) + return srv.URL, func() { srv.Close(); database.Close() } +} + +type signer struct{ key *gpg.Key } + +func (s *signer) PublicKeyArmored() (string, error) { return s.key.ArmoredPublic() } +func (s *signer) Fingerprint() string { return s.key.Fingerprint } + +func TestClientEndToEnd(t *testing.T) { + baseURL, cleanup := newServer(t) + defer cleanup() + + unauth := apiclient.New(baseURL, "") + user, token, err := unauth.Register("alice", "supersecret") + if err != nil { + t.Fatalf("Register: %v", err) + } + if user.Username != "alice" || token == "" { + t.Fatalf("bad register response: %+v", user) + } + + c := apiclient.New(baseURL, token) + + if _, err := c.Me(); err != nil { + t.Fatalf("Me: %v", err) + } + + repo, err := c.CreateRepository("myrepo", "public", "test") + if err != nil { + t.Fatalf("CreateRepository: %v", err) + } + if repo.Name != "myrepo" { + t.Fatalf("bad repo: %+v", repo) + } + + if _, err := c.CreateDistribution("myrepo", "stable"); err != nil { + t.Fatalf("CreateDistribution: %v", err) + } + if _, err := c.CreateComponent("myrepo", "stable", "main"); err != nil { + t.Fatalf("CreateComponent: %v", err) + } + if _, err := c.CreateArchitecture("myrepo", "stable", "amd64"); err != nil { + t.Fatalf("CreateArchitecture: %v", err) + } + + // Build a fixture deb and push it. + debBytes := buildDeb("hello", "1.0", "amd64") + debPath := filepath.Join(t.TempDir(), "hello_1.0_amd64.deb") + if err := os.WriteFile(debPath, debBytes, 0o644); err != nil { + t.Fatalf("write deb: %v", err) + } + pkg, err := c.PushPackage("myrepo", "stable", "main", debPath) + if err != nil { + t.Fatalf("PushPackage: %v", err) + } + if pkg.Name != "hello" || pkg.SHA256 == "" { + t.Fatalf("bad package: %+v", pkg) + } + + list, err := c.ListPackages("myrepo", "stable", map[string]string{"name": "hello"}) + if err != nil { + t.Fatalf("ListPackages: %v", err) + } + if len(list.Items) != 1 { + t.Fatalf("expected 1 package, got %d", len(list.Items)) + } + + got, err := c.GetPackage("myrepo", pkg.ID) + if err != nil { + t.Fatalf("GetPackage: %v", err) + } + if got.ID != pkg.ID { + t.Fatalf("GetPackage mismatch") + } + + // Download and compare bytes. + dlPath := filepath.Join(t.TempDir(), "pulled.deb") + if err := c.DownloadPackage("myrepo", pkg.ID, dlPath); err != nil { + t.Fatalf("DownloadPackage: %v", err) + } + dl, _ := os.ReadFile(dlPath) + if !bytes.Equal(dl, debBytes) { + t.Fatalf("downloaded bytes mismatch (%d vs %d)", len(dl), len(debBytes)) + } + + // apt-config helpers. + if _, err := c.RepositoryPubkey("myrepo"); err != nil { + t.Fatalf("RepositoryPubkey: %v", err) + } + + // Delete the package. + if err := c.DeletePackage("myrepo", pkg.ID); err != nil { + t.Fatalf("DeletePackage: %v", err) + } + list, _ = c.ListPackages("myrepo", "stable", nil) + if len(list.Items) != 0 { + t.Fatalf("expected 0 packages after delete, got %d", len(list.Items)) + } +} diff --git a/shared/apiclient/client.go b/shared/apiclient/client.go new file mode 100644 index 0000000..815cb5a --- /dev/null +++ b/shared/apiclient/client.go @@ -0,0 +1,213 @@ +// Package apiclient is the typed HTTP client used by the urapt CLI to talk to +// the urapt-server REST API. It wraps net/http with the shared API DTOs. +package apiclient + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "mime/multipart" + "net/http" + "net/url" + "strings" + + apitypes "urapt/shared/api" + "urapt/shared/httputil" + "urapt/shared/models" +) + +// Client is an authenticated HTTP client for the urapt REST API. +type Client struct { + BaseURL string + Token string + HTTP *http.Client +} + +// New constructs a client. baseURL must not have a trailing slash. +func New(baseURL, token string) *Client { + return &Client{BaseURL: strings.TrimRight(baseURL, "/"), Token: token, HTTP: http.DefaultClient} +} + +// APIError is an error returned by the server (the error envelope). +type APIError struct { + Status int + Code string + Message string +} + +func (e *APIError) Error() string { + return fmt.Sprintf("%s (HTTP %d)", e.Message, e.Status) +} + +// IsAPIError reports whether err is an *APIError and returns it. +func IsAPIError(err error) (*APIError, bool) { + if e, ok := err.(*APIError); ok { + return e, true + } + return nil, false +} + +// do performs a JSON request and unmarshals the response into out (if non-nil +// and status is 2xx). On non-2xx it returns an *APIError. +func (c *Client) do(method, path string, body any, out any) error { + var r io.Reader + if body != nil { + b, err := json.Marshal(body) + if err != nil { + return fmt.Errorf("marshal: %w", err) + } + r = bytes.NewReader(b) + } + req, err := http.NewRequest(method, c.BaseURL+path, r) + if err != nil { + return err + } + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + if c.Token != "" { + req.Header.Set("Authorization", "Bearer "+c.Token) + } + resp, err := c.HTTP.Do(req) + if err != nil { + return fmt.Errorf("request: %w", err) + } + defer resp.Body.Close() + data, _ := io.ReadAll(resp.Body) + if resp.StatusCode >= 400 { + var env struct { + Error httputil.APIError `json:"error"` + } + _ = json.Unmarshal(data, &env) + return &APIError{Status: resp.StatusCode, Code: env.Error.Code, Message: env.Error.Message} + } + if out != nil && len(data) > 0 { + if err := json.Unmarshal(data, out); err != nil { + return fmt.Errorf("unmarshal: %w", err) + } + } + return nil +} + +// getJSON, postJSON, patchJSON, deleteJSON are convenience wrappers. +func (c *Client) getJSON(path string, out any) error { return c.do("GET", path, nil, out) } +func (c *Client) postJSON(path string, body, out any) error { + return c.do("POST", path, body, out) +} +func (c *Client) patchJSON(path string, body, out any) error { + return c.do("PATCH", path, body, out) +} +func (c *Client) deleteJSON(path string) error { return c.do("DELETE", path, nil, nil) } + +// --- server --- + +// ServerInfo fetches /server/info. +func (c *Client) ServerInfo() (*apitypes.ServerInfo, error) { + var info apitypes.ServerInfo + if err := c.getJSON("/api/v1/server/info", &info); err != nil { + return nil, err + } + return &info, nil +} + +// ServerPubkey fetches the armored default public key. +func (c *Client) ServerPubkey() (string, error) { + req, _ := http.NewRequest("GET", c.BaseURL+"/api/v1/server/pubkey", nil) + resp, err := c.HTTP.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + data, _ := io.ReadAll(resp.Body) + if resp.StatusCode >= 400 { + return "", parseErrorBody(resp.StatusCode, data) + } + return string(data), nil +} + +// parseErrorBody builds an *APIError from a non-2xx response body, decoding the +// {"error": {...}} envelope when present. +func parseErrorBody(status int, data []byte) error { + var env struct { + Error httputil.APIError `json:"error"` + } + if err := json.Unmarshal(data, &env); err == nil && env.Error.Message != "" { + return &APIError{Status: status, Code: env.Error.Code, Message: env.Error.Message} + } + return &APIError{Status: status, Message: strings.TrimSpace(string(data))} +} + +// --- auth --- + +// Register creates an account and returns the user + token. +func (c *Client) Register(username, password string) (*models.User, string, error) { + var resp apitypes.AuthResponse + if err := c.postJSON("/api/v1/auth/register", apitypes.RegisterRequest{Username: username, Password: password}, &resp); err != nil { + return nil, "", err + } + return resp.User, resp.Token, nil +} + +// Login authenticates and returns the user + token. +func (c *Client) Login(username, password string) (*models.User, string, error) { + var resp apitypes.AuthResponse + if err := c.postJSON("/api/v1/auth/login", apitypes.LoginRequest{Username: username, Password: password}, &resp); err != nil { + return nil, "", err + } + return resp.User, resp.Token, nil +} + +// Logout revokes the current token. +func (c *Client) Logout() error { return c.postJSON("/api/v1/auth/logout", nil, nil) } + +// Me returns the current user. +func (c *Client) Me() (*models.User, error) { + var u models.User + if err := c.getJSON("/api/v1/me", &u); err != nil { + return nil, err + } + return &u, nil +} + +// ListTokens returns the caller's tokens. +func (c *Client) ListTokens() ([]*models.APIToken, error) { + var resp apitypes.ListResponse[*models.APIToken] + if err := c.getJSON("/api/v1/me/tokens", &resp); err != nil { + return nil, err + } + return resp.Items, nil +} + +// CreateToken issues a new named token. +func (c *Client) CreateToken(name string) (*models.APIToken, error) { + var t models.APIToken + if err := c.postJSON("/api/v1/me/tokens", apitypes.CreateTokenRequest{Name: name}, &t); err != nil { + return nil, err + } + return &t, nil +} + +// RevokeToken revokes a token by id. +func (c *Client) RevokeToken(id string) error { return c.deleteJSON("/api/v1/me/tokens/" + id) } + +// addQuery attaches query parameters to path. +func addQuery(path string, params map[string]string) string { + if len(params) == 0 { + return path + } + v := url.Values{} + for k, val := range params { + if val != "" { + v.Set(k, val) + } + } + q := v.Encode() + if q == "" { + return path + } + return path + "?" + q +} + +// keep multipart referenced (used by PushPackage in Phase 9). +var _ = multipart.NewWriter diff --git a/shared/apiclient/helpers_test.go b/shared/apiclient/helpers_test.go new file mode 100644 index 0000000..436629c --- /dev/null +++ b/shared/apiclient/helpers_test.go @@ -0,0 +1,81 @@ +package apiclient_test + +import ( + "archive/tar" + "bytes" + "compress/gzip" + "net/http" + "net/http/httptest" +) + +func newHTTPServer(h http.Handler) *httptest.Server { + return httptest.NewServer(h) +} + +// buildDeb constructs a minimal valid .deb with the given package/version/arch. +func buildDeb(name, version, arch string) []byte { + control := "Package: " + name + "\nVersion: " + version + "\nArchitecture: " + arch + "\nMaintainer: t \nDescription: short\n extended\n" + var ctrlBuf bytes.Buffer + gz, _ := gzip.NewWriterLevel(&ctrlBuf, 9) + tw := tar.NewWriter(gz) + writeTw(tw, "control", control) + tw.Close() + gz.Close() + + var dataBuf bytes.Buffer + gz2, _ := gzip.NewWriterLevel(&dataBuf, 9) + tw2 := tar.NewWriter(gz2) + writeTw(tw2, "usr/share/"+name, "x") + tw2.Close() + gz2.Close() + + var out bytes.Buffer + out.WriteString("!\n") + writeAr(&out, "debian-binary", []byte("2.0\n")) + writeAr(&out, "control.tar.gz", ctrlBuf.Bytes()) + writeAr(&out, "data.tar.gz", dataBuf.Bytes()) + return out.Bytes() +} + +func writeTw(tw *tar.Writer, name, body string) { + _ = tw.WriteHeader(&tar.Header{Name: name, Mode: 0o644, Size: int64(len(body)), Typeflag: tar.TypeReg}) + _, _ = tw.Write([]byte(body)) +} + +func writeAr(buf *bytes.Buffer, name string, data []byte) { + header := make([]byte, 60) + for i := range header { + header[i] = ' ' + } + copy(header[0:], name+"/") + copy(header[48:], []byte(padNum(len(data), 10))) + header[58] = '`' + header[59] = '\n' + buf.Write(header) + buf.Write(data) + if len(data)%2 == 1 { + buf.WriteByte('\n') + } +} + +func padNum(n, width int) string { + s := make([]byte, width) + for i := range s { + s[i] = ' ' + } + digits := []byte(itoa(n)) + copy(s[len(s)-len(digits):], digits) + return string(s) +} + +func itoa(n int) string { + if n == 0 { + return "0" + } + var b []byte + for n > 0 { + b = append([]byte{byte('0' + n%10)}, b...) + n /= 10 + } + return string(b) +} diff --git a/shared/apiclient/packages.go b/shared/apiclient/packages.go new file mode 100644 index 0000000..728a103 --- /dev/null +++ b/shared/apiclient/packages.go @@ -0,0 +1,160 @@ +package apiclient + +import ( + "encoding/json" + "fmt" + "io" + "mime/multipart" + "net/http" + "os" + "path/filepath" + "strings" + + "urapt/shared/models" +) + +// PackageListResponse is the shape returned by the packages list endpoint. +type PackageListResponse struct { + Items []*models.Package `json:"items"` + Page int `json:"page"` + PerPage int `json:"per_page"` + Total int `json:"total"` +} + +// ListPackages lists packages in a (repo, distribution) with optional filters. +func (c *Client) ListPackages(repo, dist string, filters map[string]string) (*PackageListResponse, error) { + var resp PackageListResponse + path := addQuery("/api/v1/repositories/"+repo+"/distributions/"+dist+"/packages", filters) + if err := c.getJSON(path, &resp); err != nil { + return nil, err + } + return &resp, nil +} + +// GetPackage returns a single package by id. +func (c *Client) GetPackage(repo, id string) (*models.Package, error) { + var p models.Package + if err := c.getJSON("/api/v1/repositories/"+repo+"/packages/"+id, &p); err != nil { + return nil, err + } + return &p, nil +} + +// PushPackage uploads a .deb file to a repository/distribution/component. The +// upload is streamed via multipart/form-data. +func (c *Client) PushPackage(repo, dist, component, filePath string) (*models.Package, error) { + f, err := os.Open(filePath) + if err != nil { + return nil, err + } + defer f.Close() + + pr, pw := io.Pipe() + writer := multipart.NewWriter(pw) + + go func() { + defer pw.Close() + _ = writer.WriteField("component", component) + part, err := writer.CreateFormFile("file", filepath.Base(filePath)) + if err != nil { + pw.CloseWithError(err) + return + } + if _, err := io.Copy(part, f); err != nil { + pw.CloseWithError(err) + return + } + _ = writer.Close() + }() + + req, err := http.NewRequest("POST", c.BaseURL+"/api/v1/repositories/"+repo+"/distributions/"+dist+"/packages", pr) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", writer.FormDataContentType()) + if c.Token != "" { + req.Header.Set("Authorization", "Bearer "+c.Token) + } + resp, err := c.HTTP.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + data, _ := io.ReadAll(resp.Body) + if resp.StatusCode >= 400 { + return nil, parseErrorBody(resp.StatusCode, data) + } + var p models.Package + if err := json.Unmarshal(data, &p); err != nil { + return nil, fmt.Errorf("unmarshal: %w", err) + } + return &p, nil +} + +// DownloadPackage fetches a package's .deb file and writes it to outFile. If +// outFile is empty, the bytes are written to stdout. +func (c *Client) DownloadPackage(repo, id, outFile string) error { + req, err := http.NewRequest("GET", c.BaseURL+"/api/v1/repositories/"+repo+"/packages/"+id+"/file", nil) + if err != nil { + return err + } + if c.Token != "" { + req.Header.Set("Authorization", "Bearer "+c.Token) + } + resp, err := c.HTTP.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode >= 400 { + data, _ := io.ReadAll(resp.Body) + return parseErrorBody(resp.StatusCode, data) + } + var w io.Writer + if outFile == "" || outFile == "-" { + w = os.Stdout + } else { + f, err := os.Create(outFile) + if err != nil { + return err + } + defer f.Close() + w = f + } + _, err = io.Copy(w, resp.Body) + return err +} + +// DeletePackage removes a package by id. +func (c *Client) DeletePackage(repo, id string) error { + return c.deleteJSON("/api/v1/repositories/" + repo + "/packages/" + id) +} + +// ParsePackageSpec splits a "name[@version][:arch]" specifier into its parts. +// A string that looks like a UUID is treated as an id. +func ParsePackageSpec(spec string) (id, name, version, arch string) { + spec = strings.TrimSpace(spec) + if isUUID(spec) { + return spec, "", "", "" + } + // split :arch + if i := strings.IndexByte(spec, ':'); i >= 0 { + arch = spec[i+1:] + spec = spec[:i] + } + // split @version + if i := strings.IndexByte(spec, '@'); i >= 0 { + version = spec[i+1:] + spec = spec[:i] + } + name = spec + return "", name, version, arch +} + +// isUUID reports whether s looks like a UUIDv4. +func isUUID(s string) bool { + if len(s) != 36 { + return false + } + return s[8] == '-' && s[13] == '-' && s[18] == '-' && s[23] == '-' +} diff --git a/shared/apiclient/repos.go b/shared/apiclient/repos.go new file mode 100644 index 0000000..a5315b7 --- /dev/null +++ b/shared/apiclient/repos.go @@ -0,0 +1,205 @@ +package apiclient + +import ( + "io" + "net/http" + + apitypes "urapt/shared/api" + "urapt/shared/models" +) + +// newGetReq builds a GET request to url. +func newGetReq(url string) (*http.Request, error) { + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return nil, err + } + return req, nil +} + +// readBody fully reads a response body. +func readBody(resp *http.Response) ([]byte, error) { + return io.ReadAll(resp.Body) +} + +// --- repositories --- + +// ListRepositories returns repositories visible to the caller. +func (c *Client) ListRepositories() ([]*models.Repository, error) { + var resp apitypes.ListResponse[*models.Repository] + if err := c.getJSON("/api/v1/repositories", &resp); err != nil { + return nil, err + } + return resp.Items, nil +} + +// CreateRepository creates a new repository. +func (c *Client) CreateRepository(name, visibility, description string) (*models.Repository, error) { + var repo models.Repository + if err := c.postJSON("/api/v1/repositories", apitypes.CreateRepoRequest{ + Name: name, Visibility: visibility, Description: description, + }, &repo); err != nil { + return nil, err + } + return &repo, nil +} + +// GetRepository returns a single repository by name. +func (c *Client) GetRepository(name string) (*models.Repository, error) { + var repo models.Repository + if err := c.getJSON("/api/v1/repositories/"+name, &repo); err != nil { + return nil, err + } + return &repo, nil +} + +// UpdateRepository mutates a repository. nil arguments leave fields unchanged. +func (c *Client) UpdateRepository(name string, newName *string, visibility *string, description *string) (*models.Repository, error) { + var repo models.Repository + if err := c.patchJSON("/api/v1/repositories/"+name, apitypes.UpdateRepoRequest{ + Name: newName, Visibility: visibility, Description: description, + }, &repo); err != nil { + return nil, err + } + return &repo, nil +} + +// DeleteRepository removes a repository. +func (c *Client) DeleteRepository(name string) error { + return c.deleteJSON("/api/v1/repositories/" + name) +} + +// RepositoryPubkey returns the server's armored public key (repo-scoped path). +func (c *Client) RepositoryPubkey(name string) (string, error) { + req, err := newGetReq(c.BaseURL + "/api/v1/repositories/" + name + "/pubkey") + if err != nil { + return "", err + } + if c.Token != "" { + req.Header.Set("Authorization", "Bearer "+c.Token) + } + resp, err := c.HTTP.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + data, _ := readBody(resp) + if resp.StatusCode >= 400 { + return "", parseErrorBody(resp.StatusCode, data) + } + return string(data), nil +} + +// --- members --- + +// ListMembers returns the members of a repository. +func (c *Client) ListMembers(repo string) ([]*models.RepositoryMember, error) { + var out []*models.RepositoryMember + if err := c.getJSON("/api/v1/repositories/"+repo+"/members", &out); err != nil { + return nil, err + } + return out, nil +} + +// AddMember grants a user access on a repository. +func (c *Client) AddMember(repo, username, access string) (*models.RepositoryMember, error) { + var m models.RepositoryMember + if err := c.postJSON("/api/v1/repositories/"+repo+"/members", + apitypes.AddMemberRequest{Username: username, Access: access}, &m); err != nil { + return nil, err + } + return &m, nil +} + +// UpdateMember changes a member's access. +func (c *Client) UpdateMember(repo, username, access string) (*models.RepositoryMember, error) { + var m models.RepositoryMember + if err := c.patchJSON("/api/v1/repositories/"+repo+"/members/"+username, + apitypes.UpdateMemberRequest{Access: access}, &m); err != nil { + return nil, err + } + return &m, nil +} + +// RemoveMember revokes a user's access. +func (c *Client) RemoveMember(repo, username string) error { + return c.deleteJSON("/api/v1/repositories/" + repo + "/members/" + username) +} + +// --- distributions --- + +// ListDistributions returns the distributions in a repository. +func (c *Client) ListDistributions(repo string) ([]*models.Distribution, error) { + var out []*models.Distribution + if err := c.getJSON("/api/v1/repositories/"+repo+"/distributions", &out); err != nil { + return nil, err + } + return out, nil +} + +// CreateDistribution adds a distribution. +func (c *Client) CreateDistribution(repo, name string) (*models.Distribution, error) { + var d models.Distribution + if err := c.postJSON("/api/v1/repositories/"+repo+"/distributions", + apitypes.CreateNamedRequest{Name: name}, &d); err != nil { + return nil, err + } + return &d, nil +} + +// DeleteDistribution removes a distribution. +func (c *Client) DeleteDistribution(repo, name string) error { + return c.deleteJSON("/api/v1/repositories/" + repo + "/distributions/" + name) +} + +// --- components --- + +// ListComponents returns the components in a distribution. +func (c *Client) ListComponents(repo, dist string) ([]*models.Component, error) { + var out []*models.Component + if err := c.getJSON("/api/v1/repositories/"+repo+"/distributions/"+dist+"/components", &out); err != nil { + return nil, err + } + return out, nil +} + +// CreateComponent adds a component. +func (c *Client) CreateComponent(repo, dist, name string) (*models.Component, error) { + var comp models.Component + if err := c.postJSON("/api/v1/repositories/"+repo+"/distributions/"+dist+"/components", + apitypes.CreateNamedRequest{Name: name}, &comp); err != nil { + return nil, err + } + return &comp, nil +} + +// DeleteComponent removes a component. +func (c *Client) DeleteComponent(repo, dist, name string) error { + return c.deleteJSON("/api/v1/repositories/" + repo + "/distributions/" + dist + "/components/" + name) +} + +// --- architectures --- + +// ListArchitectures returns the architectures in a distribution. +func (c *Client) ListArchitectures(repo, dist string) ([]*models.Architecture, error) { + var out []*models.Architecture + if err := c.getJSON("/api/v1/repositories/"+repo+"/distributions/"+dist+"/architectures", &out); err != nil { + return nil, err + } + return out, nil +} + +// CreateArchitecture adds an architecture. +func (c *Client) CreateArchitecture(repo, dist, name string) (*models.Architecture, error) { + var a models.Architecture + if err := c.postJSON("/api/v1/repositories/"+repo+"/distributions/"+dist+"/architectures", + apitypes.CreateNamedRequest{Name: name}, &a); err != nil { + return nil, err + } + return &a, nil +} + +// DeleteArchitecture removes an architecture. +func (c *Client) DeleteArchitecture(repo, dist, name string) error { + return c.deleteJSON("/api/v1/repositories/" + repo + "/distributions/" + dist + "/architectures/" + name) +} diff --git a/shared/apt/apt.go b/shared/apt/apt.go new file mode 100644 index 0000000..8307700 --- /dev/null +++ b/shared/apt/apt.go @@ -0,0 +1,267 @@ +// Package apt generates APT repository indices (Packages, Release, InRelease, +// Release.gpg) entirely from in-memory package data. Indices are never written +// to disk by this package; the caller caches and serves them. +package apt + +import ( + "bytes" + "compress/gzip" + "crypto/md5" + "crypto/sha1" + "crypto/sha256" + "encoding/hex" + "fmt" + "sort" + "strings" + "time" + + "github.com/ulikunitz/xz" +) + +// Signer is implemented by anything able to clearsign and detached-sign the +// Release file (e.g. *gpg.Key). +type Signer interface { + ClearSign(data []byte) ([]byte, error) + DetachedSign(data []byte) ([]byte, error) +} + +// PackageRow is a single package's data needed to emit its index entry. +type PackageRow struct { + Component string + Name string + Version string + Architecture string + PoolPath string + Size int64 + MD5sum string + SHA1 string + SHA256 string + DescriptionMD5 string + RawControl string +} + +// Suite describes a (repository, distribution) for which to generate indices. +type Suite struct { + Origin string + Label string + Suite string + Codename string + Description string + Components []string + Architectures []string + Packages []PackageRow +} + +// Indices holds all generated index artifacts for a suite, keyed by their path +// relative to the suite directory. +type Indices struct { + Packages map[string][]byte + PackagesGz map[string][]byte + PackagesXz map[string][]byte + Release []byte + InRelease []byte + ReleaseGpg []byte +} + +// Generate builds all indices for the suite and signs the Release file using +// signer. If signer is nil, InRelease/ReleaseGpg are left empty. +func Generate(s *Suite, signer Signer) (*Indices, error) { + components := dedupSorted(s.Components) + arches := dedupSorted(s.Architectures) + + idx := &Indices{ + Packages: map[string][]byte{}, + PackagesGz: map[string][]byte{}, + PackagesXz: map[string][]byte{}, + } + + // Group packages by (component, arch), including arch="all" in every arch. + type key struct{ comp, arch string } + groups := map[key][]PackageRow{} + for _, p := range s.Packages { + for _, arch := range arches { + if p.Architecture == arch || p.Architecture == "all" { + k := key{p.Component, arch} + groups[k] = append(groups[k], p) + } + } + } + + for _, comp := range components { + for _, arch := range arches { + rows := groups[key{comp, arch}] + sort.SliceStable(rows, func(i, j int) bool { + if rows[i].Name != rows[j].Name { + return rows[i].Name < rows[j].Name + } + return rows[i].Version < rows[j].Version + }) + pkgBytes := generatePackagesIndex(rows) + relPath := fmt.Sprintf("%s/binary-%s/Packages", comp, arch) + idx.Packages[relPath] = pkgBytes + idx.PackagesGz[relPath+".gz"] = gzipBytes(pkgBytes) + xzBytes, err := xzBytes(pkgBytes) + if err != nil { + return nil, fmt.Errorf("xz compress %s: %w", relPath, err) + } + idx.PackagesXz[relPath+".xz"] = xzBytes + } + } + + release, err := generateRelease(s, components, arches, idx) + if err != nil { + return nil, err + } + idx.Release = release + + if signer != nil { + clear, err := signer.ClearSign(release) + if err != nil { + return nil, fmt.Errorf("clearsign: %w", err) + } + idx.InRelease = clear + det, err := signer.DetachedSign(release) + if err != nil { + return nil, fmt.Errorf("detach sign: %w", err) + } + idx.ReleaseGpg = det + } + return idx, nil +} + +// generatePackagesIndex emits the Packages file body for one (component, arch). +// The result is always non-nil (an empty byte slice if there are no rows). +func generatePackagesIndex(rows []PackageRow) []byte { + var buf bytes.Buffer + for _, r := range rows { + stanza := strings.TrimRight(r.RawControl, "\n") + buf.WriteString(stanza) + buf.WriteByte('\n') + writeField(&buf, "Filename", r.PoolPath) + writeFieldInt(&buf, "Size", r.Size) + writeField(&buf, "MD5sum", r.MD5sum) + writeField(&buf, "SHA1", r.SHA1) + writeField(&buf, "SHA256", r.SHA256) + if r.DescriptionMD5 != "" { + writeField(&buf, "Description-md5", r.DescriptionMD5) + } + buf.WriteByte('\n') + } + out := buf.Bytes() + if out == nil { + out = []byte{} + } + return out +} + +// fileEntry is one index file's path and bytes, used for Release checksums. +type fileEntry struct { + path string + data []byte +} + +func (e fileEntry) md5() string { sum := md5.Sum(e.data); return hex.EncodeToString(sum[:]) } +func (e fileEntry) sha1() string { sum := sha1.Sum(e.data); return hex.EncodeToString(sum[:]) } +func (e fileEntry) sha256() string { + sum := sha256.Sum256(e.data) + return hex.EncodeToString(sum[:]) +} + +func generateRelease(s *Suite, components, arches []string, idx *Indices) ([]byte, error) { + var entries []fileEntry + for path, data := range idx.Packages { + entries = append(entries, fileEntry{path: path, data: data}) + } + for path, data := range idx.PackagesGz { + entries = append(entries, fileEntry{path: path, data: data}) + } + for path, data := range idx.PackagesXz { + entries = append(entries, fileEntry{path: path, data: data}) + } + sort.Slice(entries, func(i, j int) bool { return entries[i].path < entries[j].path }) + + var buf bytes.Buffer + if s.Origin != "" { + writeField(&buf, "Origin", s.Origin) + } + if s.Label != "" { + writeField(&buf, "Label", s.Label) + } + writeField(&buf, "Suite", s.Suite) + codename := s.Codename + if codename == "" { + codename = s.Suite + } + writeField(&buf, "Codename", codename) + writeField(&buf, "Date", time.Now().UTC().Format("Mon, 02 Jan 2006 15:04:05 MST")) + if s.Description != "" { + writeField(&buf, "Description", s.Description) + } + if len(arches) > 0 { + writeField(&buf, "Architectures", strings.Join(arches, " ")) + } + if len(components) > 0 { + writeField(&buf, "Components", strings.Join(components, " ")) + } + + writeChecksumBlock(&buf, "MD5Sum", entries, func(e fileEntry) string { return e.md5() }) + writeChecksumBlock(&buf, "SHA1", entries, func(e fileEntry) string { return e.sha1() }) + writeChecksumBlock(&buf, "SHA256", entries, func(e fileEntry) string { return e.sha256() }) + return buf.Bytes(), nil +} + +func writeChecksumBlock(buf *bytes.Buffer, name string, entries []fileEntry, hashFn func(fileEntry) string) { + buf.WriteString(name + ":\n") + for _, e := range entries { + fmt.Fprintf(buf, " %s %16d %s\n", hashFn(e), len(e.data), e.path) + } +} + +func writeField(buf *bytes.Buffer, key, val string) { + if val == "" { + return + } + fmt.Fprintf(buf, "%s: %s\n", key, val) +} + +func writeFieldInt(buf *bytes.Buffer, key string, val int64) { + fmt.Fprintf(buf, "%s: %d\n", key, val) +} + +func gzipBytes(data []byte) []byte { + var buf bytes.Buffer + gz := gzip.NewWriter(&buf) + _, _ = gz.Write(data) + _ = gz.Close() + return buf.Bytes() +} + +func xzBytes(data []byte) ([]byte, error) { + var buf bytes.Buffer + xw, err := xz.NewWriter(&buf) + if err != nil { + return nil, err + } + if _, err := xw.Write(data); err != nil { + _ = xw.Close() + return nil, err + } + if err := xw.Close(); err != nil { + return nil, err + } + return buf.Bytes(), nil +} + +func dedupSorted(in []string) []string { + seen := map[string]bool{} + var out []string + for _, v := range in { + if v == "" || seen[v] { + continue + } + seen[v] = true + out = append(out, v) + } + sort.Strings(out) + return out +} diff --git a/shared/apt/apt_test.go b/shared/apt/apt_test.go new file mode 100644 index 0000000..fa6a440 --- /dev/null +++ b/shared/apt/apt_test.go @@ -0,0 +1,130 @@ +package apt + +import ( + "bytes" + "crypto/sha256" + "encoding/hex" + "strings" + "testing" + + "urapt/shared/gpg" +) + +func TestGenerateIndices(t *testing.T) { + key, err := gpg.GenerateKey("urapt-test ", 2048) + if err != nil { + t.Fatalf("GenerateKey: %v", err) + } + + suite := &Suite{ + Origin: "urapt myrepo", + Label: "urapt myrepo", + Suite: "stable", + Description: "my repo", + Components: []string{"main", "contrib"}, + Architectures: []string{"amd64", "arm64"}, + Packages: []PackageRow{ + { + Component: "main", Name: "foo", Version: "1.0", Architecture: "amd64", + PoolPath: "pool/main/f/foo/foo_1.0_amd64.deb", Size: 1234, + MD5sum: "aa", SHA1: "bb", SHA256: "cc", DescriptionMD5: "dd", + RawControl: "Package: foo\nVersion: 1.0\nArchitecture: amd64\nDescription: short\n", + }, + { + Component: "main", Name: "bar", Version: "2.0", Architecture: "all", + PoolPath: "pool/main/b/bar/bar_2.0_all.deb", Size: 5678, + MD5sum: "ee", SHA1: "ff", SHA256: "11", DescriptionMD5: "22", + RawControl: "Package: bar\nVersion: 2.0\nArchitecture: all\nDescription: bar short\n", + }, + }, + } + + idx, err := Generate(suite, key) + if err != nil { + t.Fatalf("Generate: %v", err) + } + + // amd64 index should contain both foo (amd64) and bar (all). + pkg := idx.Packages["main/binary-amd64/Packages"] + if pkg == nil { + t.Fatal("missing amd64 Packages") + } + if !bytes.Contains(pkg, []byte("Package: foo")) || !bytes.Contains(pkg, []byte("Package: bar")) { + t.Fatalf("amd64 index missing entries:\n%s", pkg) + } + // bar should appear in arm64 too (arch=all). + arm := idx.Packages["main/binary-arm64/Packages"] + if !bytes.Contains(arm, []byte("Package: bar")) { + t.Fatalf("arm64 index missing all-arch bar:\n%s", arm) + } + if bytes.Contains(arm, []byte("Package: foo")) { + t.Fatalf("arm64 index should not contain amd64 foo:\n%s", arm) + } + // contrib indices should be empty bodies but present. + if idx.Packages["contrib/binary-amd64/Packages"] == nil { + t.Fatal("missing contrib amd64 index") + } + + // Verify file fields appended. + if !bytes.Contains(pkg, []byte("Filename: pool/main/f/foo/foo_1.0_amd64.deb")) { + t.Fatalf("Packages missing Filename:\n%s", pkg) + } + if !bytes.Contains(pkg, []byte("SHA256: cc")) { + t.Fatalf("Packages missing SHA256:\n%s", pkg) + } + if !bytes.Contains(pkg, []byte("Description-md5: dd")) { + t.Fatalf("Packages missing Description-md5:\n%s", pkg) + } + + // Release file should list components, architectures, and checksums. + rel := idx.Release + if !bytes.Contains(rel, []byte("Suite: stable")) { + t.Fatalf("Release missing Suite:\n%s", rel) + } + if !bytes.Contains(rel, []byte("Components: contrib main")) { + t.Fatalf("Release missing Components:\n%s", rel) + } + if !bytes.Contains(rel, []byte("Architectures: amd64 arm64")) { + t.Fatalf("Release missing Architectures:\n%s", rel) + } + if !bytes.Contains(rel, []byte("SHA256:")) { + t.Fatalf("Release missing SHA256 block:\n%s", rel) + } + if !bytes.Contains(rel, []byte("main/binary-amd64/Packages")) { + t.Fatalf("Release missing index path:\n%s", rel) + } + + // InRelease must be a clearsigned block. + if !bytes.Contains(idx.InRelease, []byte("BEGIN PGP SIGNED MESSAGE")) { + t.Fatalf("InRelease not clearsigned:\n%s", idx.InRelease) + } + // Release.gpg must be an armored detached signature. + if !bytes.Contains(idx.ReleaseGpg, []byte("BEGIN PGP SIGNATURE")) { + t.Fatalf("Release.gpg not armored sig:\n%s", idx.ReleaseGpg) + } + + // Verify the clearsign and detached signatures with the public key. + pub, err := key.ArmoredPublic() + if err != nil { + t.Fatalf("ArmoredPublic: %v", err) + } + if _, err := gpg.VerifyClearSign(pub, idx.InRelease); err != nil { + t.Fatalf("verify InRelease: %v", err) + } + if err := gpg.VerifyDetached(pub, idx.Release, idx.ReleaseGpg); err != nil { + t.Fatalf("verify Release.gpg: %v", err) + } + + // Checksum correctness: the Release SHA256 entry for the Packages file must + // match the bytes we generated. + want := sha256hex(pkg) + if !bytes.Contains(rel, []byte(" "+want)) { + t.Fatalf("Release missing correct Packages sha256 %s:\n%s", want, rel) + } + _ = strings.Repeat +} + +func sha256hex(data []byte) string { + sum := sha256.Sum256(data) + return hex.EncodeToString(sum[:]) +} diff --git a/shared/apt/pool.go b/shared/apt/pool.go new file mode 100644 index 0000000..161f4ce --- /dev/null +++ b/shared/apt/pool.go @@ -0,0 +1,28 @@ +package apt + +import "strings" + +// PoolPath computes the conventional Debian pool path for a package: +// pool////, where is the source package +// name (or the binary package name if absent) and follows the Debian +// "libX" convention. +func PoolPath(component, source, packageName, filename string) string { + src := source + if src == "" { + src = packageName + } + letter := poolLetter(src) + return strings.Join([]string{"pool", component, letter, src, filename}, "/") +} + +// poolLetter returns the pool subdirectory prefix for a source name: "lib" + +// next char for names starting with "lib", otherwise the first character. +func poolLetter(src string) string { + if len(src) >= 4 && strings.HasPrefix(src, "lib") { + return "lib" + string(src[3]) + } + if src == "" { + return "0" + } + return string(src[0]) +} diff --git a/shared/config/config.go b/shared/config/config.go new file mode 100644 index 0000000..2d0036a --- /dev/null +++ b/shared/config/config.go @@ -0,0 +1,221 @@ +// Package config defines the urapt-server configuration and its loading from +// defaults, a TOML file, environment variables, and command-line flags, with +// later sources overriding earlier ones. +package config + +import ( + "fmt" + "os" + "path/filepath" + "strconv" + "strings" + + "github.com/BurntSushi/toml" +) + +// Defaults applied before any other source. +var Defaults = Config{ + Bind: "0.0.0.0:8080", + BaseURL: "http://localhost:8080", + StoreDir: "./store", + LogLevel: "info", + SigningKeyType: "rsa", + SigningKeyBits: 4096, + MaxPackageSize: 1024 * 1024 * 1024, + OpenRegistration: true, + ConfigPath: "./urapt-server.toml", +} + +// Config is the urapt-server runtime configuration. +type Config struct { + Bind string `toml:"bind"` + BaseURL string `toml:"base_url"` + StoreDir string `toml:"store_dir"` + DBPath string `toml:"db_path"` + PackagesDir string `toml:"packages_dir"` + LogLevel string `toml:"log_level"` + SigningKeyType string `toml:"signing_key_type"` + SigningKeyBits int `toml:"signing_key_bits"` + SigningKeyUserID string `toml:"signing_key_user_id"` + MaxPackageSize int64 `toml:"max_package_size"` + OpenRegistration bool `toml:"open_registration"` + TLSEnabled bool `toml:"tls_enabled"` + TLSCert string `toml:"tls_cert"` + TLSKey string `toml:"tls_key"` + + ConfigPath string `toml:"-"` +} + +// Load builds the effective Config from Defaults -> file -> env -> flags. +// Flags is a map of flag name to string value (already parsed by the caller). +func Load(configPath string, flags map[string]string) (Config, error) { + c := Defaults + c.ConfigPath = configPath + + if err := applyFile(&c, configPath); err != nil { + return Config{}, err + } + applyEnv(&c) + if err := applyFlags(&c, flags); err != nil { + return Config{}, err + } + c.finalize() + return c, nil +} + +func applyFile(c *Config, path string) error { + if path == "" { + return nil + } + data, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return nil + } + return fmt.Errorf("read config %s: %w", path, err) + } + if err := toml.Unmarshal(data, c); err != nil { + return fmt.Errorf("parse config %s: %w", path, err) + } + return nil +} + +func applyEnv(c *Config) { + set(c, "URAPT_BIND", &c.Bind) + set(c, "URAPT_BASE_URL", &c.BaseURL) + set(c, "URAPT_STORE_DIR", &c.StoreDir) + set(c, "URAPT_DB_PATH", &c.DBPath) + set(c, "URAPT_PACKAGES_DIR", &c.PackagesDir) + set(c, "URAPT_LOG_LEVEL", &c.LogLevel) + set(c, "URAPT_SIGNING_KEY_TYPE", &c.SigningKeyType) + set(c, "URAPT_SIGNING_KEY_USER_ID", &c.SigningKeyUserID) + set(c, "URAPT_TLS_CERT", &c.TLSCert) + set(c, "URAPT_TLS_KEY", &c.TLSKey) + setInt(c, "URAPT_SIGNING_KEY_BITS", &c.SigningKeyBits) + setInt64(c, "URAPT_MAX_PACKAGE_SIZE", &c.MaxPackageSize) + setBool(c, "URAPT_OPEN_REGISTRATION", &c.OpenRegistration) + setBool(c, "URAPT_TLS_ENABLED", &c.TLSEnabled) +} + +func applyFlags(c *Config, flags map[string]string) error { + for k, v := range flags { + switch k { + case "bind": + c.Bind = v + case "base-url": + c.BaseURL = v + case "store-dir": + c.StoreDir = v + case "db-path": + c.DBPath = v + case "packages-dir": + c.PackagesDir = v + case "log-level": + c.LogLevel = v + case "signing-key-type": + c.SigningKeyType = v + case "signing-key-bits": + n, err := strconv.Atoi(v) + if err != nil { + return fmt.Errorf("invalid --signing-key-bits %q: %w", v, err) + } + c.SigningKeyBits = n + case "signing-key-user-id": + c.SigningKeyUserID = v + case "max-package-size": + n, err := strconv.ParseInt(v, 10, 64) + if err != nil { + return fmt.Errorf("invalid --max-package-size %q: %w", v, err) + } + c.MaxPackageSize = n + case "open-registration": + b, err := strconv.ParseBool(v) + if err != nil { + return fmt.Errorf("invalid --open-registration %q: %w", v, err) + } + c.OpenRegistration = b + case "tls-enabled": + b, err := strconv.ParseBool(v) + if err != nil { + return fmt.Errorf("invalid --tls-enabled %q: %w", v, err) + } + c.TLSEnabled = b + case "tls-cert": + c.TLSCert = v + case "tls-key": + c.TLSKey = v + case "config": + // already handled + } + } + return nil +} + +// finalize fills derived defaults: DBPath and PackagesDir default under +// StoreDir, and SigningKeyUserID gets a hostname-based default. +func (c *Config) finalize() { + if c.DBPath == "" { + c.DBPath = filepath.Join(c.StoreDir, "database", "sqlite.db") + } + if c.PackagesDir == "" { + c.PackagesDir = filepath.Join(c.StoreDir, "packages") + } + if c.SigningKeyUserID == "" { + host, err := os.Hostname() + if err != nil || host == "" { + host = "localhost" + } + c.SigningKeyUserID = "urapt-server <" + host + ">" + } + if c.MaxPackageSize <= 0 { + c.MaxPackageSize = Defaults.MaxPackageSize + } +} + +// Validate checks the config for obvious errors before startup. +func (c *Config) Validate() error { + if c.Bind == "" { + return fmt.Errorf("bind address is required") + } + if c.BaseURL == "" { + return fmt.Errorf("base_url is required") + } + c.BaseURL = strings.TrimRight(c.BaseURL, "/") + if c.SigningKeyBits <= 0 { + return fmt.Errorf("signing_key_bits must be positive") + } + if c.TLSEnabled && (c.TLSCert == "" || c.TLSKey == "") { + return fmt.Errorf("tls_enabled requires tls_cert and tls_key") + } + return nil +} + +func set(c *Config, env string, dst *string) { + if v, ok := os.LookupEnv(env); ok && v != "" { + *dst = v + } +} + +func setInt(c *Config, env string, dst *int) { + if v, ok := os.LookupEnv(env); ok && v != "" { + if n, err := strconv.Atoi(v); err == nil { + *dst = n + } + } +} + +func setInt64(c *Config, env string, dst *int64) { + if v, ok := os.LookupEnv(env); ok && v != "" { + if n, err := strconv.ParseInt(v, 10, 64); err == nil { + *dst = n + } + } +} + +func setBool(c *Config, env string, dst *bool) { + if v, ok := os.LookupEnv(env); ok && v != "" { + if b, err := strconv.ParseBool(v); err == nil { + *dst = b + } + } +} diff --git a/shared/crypto/crypto.go b/shared/crypto/crypto.go new file mode 100644 index 0000000..5a6b795 --- /dev/null +++ b/shared/crypto/crypto.go @@ -0,0 +1,51 @@ +// Package crypto provides password hashing and API token generation utilities +// shared by the server and (for verification symmetry) tests. +package crypto + +import ( + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "fmt" + + "golang.org/x/crypto/bcrypt" +) + +// TokenPrefix is the textual prefix attached to all urapt API tokens so they +// are easy to identify and not confused with other secrets. +const TokenPrefix = "urapt_" + +// HashPassword returns a bcrypt hash of the given plaintext password. +func HashPassword(password string) (string, error) { + h, err := bcrypt.GenerateFromPassword([]byte(password), 12) + if err != nil { + return "", fmt.Errorf("bcrypt: %w", err) + } + return string(h), nil +} + +// VerifyPassword reports whether password matches the stored bcrypt hash. +func VerifyPassword(hash, password string) bool { + return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) == nil +} + +// GenerateToken creates a new random API token (TokenPrefix + base64url of 32 +// random bytes) and returns it together with its SHA-256 hash (for storage) +// and an 8-char display prefix. +func GenerateToken() (token, hash, prefix string, err error) { + raw := make([]byte, 32) + if _, err = rand.Read(raw); err != nil { + return "", "", "", fmt.Errorf("rand: %w", err) + } + body := base64.RawURLEncoding.EncodeToString(raw) + token = TokenPrefix + body + hash = HashToken(token) + prefix = token[:len(TokenPrefix)+8] + return token, hash, prefix, nil +} + +// HashToken returns the SHA-256 hex digest of a token, for storage/lookup. +func HashToken(token string) string { + sum := sha256.Sum256([]byte(token)) + return fmt.Sprintf("%x", sum) +} diff --git a/shared/crypto/crypto_test.go b/shared/crypto/crypto_test.go new file mode 100644 index 0000000..82ce3e8 --- /dev/null +++ b/shared/crypto/crypto_test.go @@ -0,0 +1,43 @@ +package crypto + +import "testing" + +func TestHashAndVerifyPassword(t *testing.T) { + h, err := HashPassword("hunter2") + if err != nil { + t.Fatalf("HashPassword: %v", err) + } + if !VerifyPassword(h, "hunter2") { + t.Fatal("expected verify to pass for correct password") + } + if VerifyPassword(h, "wrong") { + t.Fatal("expected verify to fail for wrong password") + } +} + +func TestGenerateToken(t *testing.T) { + tok, hash, prefix, err := GenerateToken() + if err != nil { + t.Fatalf("GenerateToken: %v", err) + } + if tok == "" || hash == "" || prefix == "" { + t.Fatal("empty token fields") + } + if len(tok) <= len(TokenPrefix) { + t.Fatal("token too short") + } + if tok[:len(TokenPrefix)] != TokenPrefix { + t.Fatalf("token missing prefix: %q", tok) + } + if HashToken(tok) != hash { + t.Fatal("HashToken does not match returned hash") + } + if prefix != tok[:len(TokenPrefix)+8] { + t.Fatalf("prefix %q != expected", prefix) + } + + tok2, _, _, _ := GenerateToken() + if tok == tok2 { + t.Fatal("expected distinct tokens") + } +} diff --git a/shared/db/db.go b/shared/db/db.go new file mode 100644 index 0000000..0e81a73 --- /dev/null +++ b/shared/db/db.go @@ -0,0 +1,128 @@ +// Package db opens the urapt SQLite database (pure-Go modernc driver, no CGO), +// enables WAL mode and foreign keys, and applies embedded SQL migrations. +package db + +import ( + "context" + "database/sql" + "embed" + "fmt" + "os" + "path/filepath" + "sort" + "strconv" + "strings" + "time" + + _ "modernc.org/sqlite" +) + +//go:embed all:migrations +var migrationsFS embed.FS + +// Open opens (or creates) the SQLite database at path, applies pragmas and +// pending migrations, and returns the *sql.DB. The parent directory is created +// if missing. +func Open(path string) (*sql.DB, error) { + dir := filepath.Dir(path) + if err := mkdirAll(dir); err != nil { + return nil, fmt.Errorf("create db dir: %w", err) + } + + dsn := "file:" + path + "?_pragma=busy_timeout(5000)&_pragma=foreign_keys(1)&_pragma=journal_mode(WAL)&_pragma=synchronous(NORMAL)" + db, err := sql.Open("sqlite", dsn) + if err != nil { + return nil, fmt.Errorf("open sqlite: %w", err) + } + db.SetMaxOpenConns(1) // SQLite serial writers; reads still concurrent under WAL via SetMaxOpenConns handling + + if err := db.PingContext(context.Background()); err != nil { + _ = db.Close() + return nil, fmt.Errorf("ping sqlite: %w", err) + } + + if err := Migrate(context.Background(), db); err != nil { + _ = db.Close() + return nil, err + } + return db, nil +} + +// Migrate applies any embedded SQL migrations not yet recorded in +// schema_migrations. +func Migrate(ctx context.Context, db *sql.DB) error { + if _, err := db.ExecContext(ctx, `CREATE TABLE IF NOT EXISTS schema_migrations ( + version INTEGER PRIMARY KEY, + applied_at TEXT NOT NULL + )`); err != nil { + return fmt.Errorf("ensure migrations table: %w", err) + } + + names, err := migrationsFS.ReadDir("migrations") + if err != nil { + return fmt.Errorf("read migrations: %w", err) + } + var files []string + for _, e := range names { + if !e.IsDir() && strings.HasSuffix(e.Name(), ".sql") { + files = append(files, e.Name()) + } + } + sort.Strings(files) + + for _, f := range files { + version, err := migrationVersion(f) + if err != nil { + return fmt.Errorf("parse migration name %s: %w", f, err) + } + var applied int + err = db.QueryRowContext(ctx, `SELECT COUNT(*) FROM schema_migrations WHERE version = ?`, version).Scan(&applied) + if err != nil { + return fmt.Errorf("check migration %d: %w", version, err) + } + if applied > 0 { + continue + } + data, err := migrationsFS.ReadFile("migrations/" + f) + if err != nil { + return fmt.Errorf("read migration %s: %w", f, err) + } + if _, err := db.ExecContext(ctx, string(data)); err != nil { + return fmt.Errorf("apply migration %s: %w", f, err) + } + if _, err := db.ExecContext(ctx, `INSERT INTO schema_migrations (version, applied_at) VALUES (?, ?)`, version, nowISO()); err != nil { + return fmt.Errorf("record migration %d: %w", version, err) + } + } + return nil +} + +// nowISO returns the current UTC time in RFC3339 form. +func nowISO() string { + return time.Now().UTC().Format(time.RFC3339Nano) +} + +// migrationVersion extracts the leading numeric component of a migration +// filename such as "0001_init.sql" -> 1. +func migrationVersion(name string) (int, error) { + name = strings.TrimSuffix(name, ".sql") + var num string + for _, r := range name { + if r >= '0' && r <= '9' { + num += string(r) + continue + } + break + } + if num == "" { + return 0, fmt.Errorf("no leading digits in %q", name) + } + return strconv.Atoi(num) +} + +func mkdirAll(dir string) error { + if dir == "" { + return nil + } + return os.MkdirAll(dir, 0o755) +} diff --git a/shared/db/db_test.go b/shared/db/db_test.go new file mode 100644 index 0000000..c0f4d4f --- /dev/null +++ b/shared/db/db_test.go @@ -0,0 +1,51 @@ +package db + +import ( + "context" + "path/filepath" + "testing" +) + +func TestOpenAndMigrate(t *testing.T) { + dir := t.TempDir() + db, err := Open(filepath.Join(dir, "test.db")) + if err != nil { + t.Fatalf("Open: %v", err) + } + defer db.Close() + + var n int + err = db.QueryRowContext(context.Background(), + `SELECT COUNT(*) FROM sqlite_master WHERE type='table'`).Scan(&n) + if err != nil { + t.Fatalf("query tables: %v", err) + } + if n < 10 { + t.Fatalf("expected at least 10 tables, got %d", n) + } + + var v int + err = db.QueryRowContext(context.Background(), + `SELECT version FROM schema_migrations WHERE version=1`).Scan(&v) + if err != nil { + t.Fatalf("migration not recorded: %v", err) + } + if v != 1 { + t.Fatalf("expected version 1, got %d", v) + } +} + +func TestMigrateIdempotent(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "test.db") + db, err := Open(path) + if err != nil { + t.Fatalf("Open first: %v", err) + } + db.Close() + db2, err := Open(path) + if err != nil { + t.Fatalf("Open second: %v", err) + } + defer db2.Close() +} diff --git a/shared/db/migrations/0001_init.sql b/shared/db/migrations/0001_init.sql new file mode 100644 index 0000000..55f1f80 --- /dev/null +++ b/shared/db/migrations/0001_init.sql @@ -0,0 +1,144 @@ +-- 0001_init.sql: initial urapt schema. + +CREATE TABLE users ( + id TEXT PRIMARY KEY, + username TEXT UNIQUE NOT NULL, + username_lc TEXT UNIQUE NOT NULL, + password_hash TEXT NOT NULL, + is_admin INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +CREATE TABLE api_tokens ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + name TEXT NOT NULL, + prefix TEXT NOT NULL, + token_hash TEXT UNIQUE NOT NULL, + created_at TEXT NOT NULL, + last_used_at TEXT, + revoked_at TEXT +); + +CREATE INDEX idx_api_tokens_user ON api_tokens(user_id); +CREATE INDEX idx_api_tokens_hash ON api_tokens(token_hash); + +CREATE TABLE repositories ( + id TEXT PRIMARY KEY, + name TEXT UNIQUE NOT NULL, + owner_user_id TEXT NOT NULL REFERENCES users(id) ON DELETE RESTRICT, + visibility TEXT NOT NULL CHECK (visibility IN ('public','private')), + description TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +CREATE TABLE repository_members ( + repository_id TEXT NOT NULL REFERENCES repositories(id) ON DELETE CASCADE, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + access TEXT NOT NULL CHECK (access IN ('read','write','read-write','admin')), + created_at TEXT NOT NULL, + PRIMARY KEY (repository_id, user_id) +); + +CREATE TABLE distributions ( + id TEXT PRIMARY KEY, + repository_id TEXT NOT NULL REFERENCES repositories(id) ON DELETE CASCADE, + name TEXT NOT NULL, + created_at TEXT NOT NULL, + UNIQUE (repository_id, name) +); + +CREATE TABLE components ( + id TEXT PRIMARY KEY, + distribution_id TEXT NOT NULL REFERENCES distributions(id) ON DELETE CASCADE, + name TEXT NOT NULL, + created_at TEXT NOT NULL, + UNIQUE (distribution_id, name) +); + +CREATE TABLE architectures ( + id TEXT PRIMARY KEY, + distribution_id TEXT NOT NULL REFERENCES distributions(id) ON DELETE CASCADE, + name TEXT NOT NULL, + created_at TEXT NOT NULL, + UNIQUE (distribution_id, name) +); + +CREATE TABLE packages ( + id TEXT PRIMARY KEY, + repository_id TEXT NOT NULL REFERENCES repositories(id) ON DELETE RESTRICT, + distribution_id TEXT NOT NULL REFERENCES distributions(id) ON DELETE CASCADE, + component_id TEXT NOT NULL REFERENCES components(id) ON DELETE RESTRICT, + name TEXT NOT NULL, + version TEXT NOT NULL, + architecture TEXT NOT NULL, + source TEXT, + maintainer TEXT, + priority TEXT, + section TEXT, + origin TEXT, + homepage TEXT, + description TEXT, + description_md5 TEXT, + depends TEXT, + pre_depends TEXT, + recommends TEXT, + suggests TEXT, + conflicts TEXT, + breaks TEXT, + provides TEXT, + replaces TEXT, + enhances TEXT, + installed_size INTEGER, + essential TEXT, + built_using TEXT, + tag TEXT, + raw_control TEXT NOT NULL, + filename TEXT NOT NULL, + pool_path TEXT NOT NULL, + size INTEGER NOT NULL, + md5sum TEXT NOT NULL, + sha1 TEXT NOT NULL, + sha256 TEXT NOT NULL, + uploaded_by_user_id TEXT NOT NULL REFERENCES users(id) ON DELETE RESTRICT, + created_at TEXT NOT NULL, + UNIQUE (repository_id, distribution_id, component_id, name, version, architecture) +); + +CREATE INDEX idx_packages_lookup ON packages(repository_id, distribution_id, component_id, name); +CREATE INDEX idx_packages_distro ON packages(repository_id, distribution_id); +CREATE INDEX idx_packages_arch ON packages(distribution_id, architecture); +CREATE INDEX idx_packages_sha256 ON packages(sha256); + +CREATE TABLE blobs ( + sha256 TEXT PRIMARY KEY, + filename TEXT NOT NULL, + size INTEGER NOT NULL, + ref_count INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL +); + +CREATE TABLE gpg_keys ( + id TEXT PRIMARY KEY, + fingerprint TEXT UNIQUE NOT NULL, + user_id TEXT NOT NULL, + public_key_armored TEXT NOT NULL, + private_key_armored TEXT NOT NULL, + is_default INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL +); + +CREATE TABLE audit_log ( + id TEXT PRIMARY KEY, + user_id TEXT REFERENCES users(id) ON DELETE SET NULL, + repository_id TEXT REFERENCES repositories(id) ON DELETE SET NULL, + action TEXT NOT NULL, + target TEXT, + details TEXT, + created_at TEXT NOT NULL +); + +CREATE INDEX idx_audit_repo ON audit_log(repository_id, created_at); +CREATE INDEX idx_audit_user ON audit_log(user_id, created_at); diff --git a/shared/deb/deb.go b/shared/deb/deb.go new file mode 100644 index 0000000..8e83026 --- /dev/null +++ b/shared/deb/deb.go @@ -0,0 +1,368 @@ +// Package deb parses Debian .deb archives: it reads the ar container, locates +// and decompresses the control.tar.* member, parses the control stanza, and +// computes whole-file hashes/sizes. It performs no execution of package +// contents. +package deb + +import ( + "archive/tar" + "bytes" + "compress/gzip" + "crypto/md5" + "crypto/sha1" + "crypto/sha256" + "encoding/hex" + "fmt" + "io" + "os" + "strings" + "unicode" + + "github.com/klauspost/compress/zstd" + "github.com/ulikunitz/xz" +) + +// Deb holds the parsed metadata of a .deb file. +type Deb struct { + Control Control + Size int64 + MD5sum string + SHA1 string + SHA256 string +} + +// Field is a single control header field, preserving original key casing. +type Field struct { + Key string + Value string +} + +// Control is a parsed control stanza. Fields preserves insertion order; +// Lookup gives case-insensitive access by key. Raw is the original text. +type Control struct { + Fields []Field + Lookup map[string]string + Raw string +} + +// Get returns the value of a field (case-insensitive), or "" if absent. +func (c Control) Get(key string) string { + if c.Lookup == nil { + return "" + } + return c.Lookup[strings.ToLower(key)] +} + +// Inspect opens the .deb at path, computes whole-file hashes/size, and parses +// its control stanza. +func Inspect(path string) (*Deb, error) { + f, err := os.Open(path) + if err != nil { + return nil, fmt.Errorf("open deb: %w", err) + } + defer f.Close() + + stat, err := f.Stat() + if err != nil { + return nil, fmt.Errorf("stat deb: %w", err) + } + + hMD5 := md5.New() + hSHA1 := sha1.New() + hSHA256 := sha256.New() + size := stat.Size() + + if _, err := io.Copy(io.MultiWriter(hMD5, hSHA1, hSHA256), f); err != nil { + return nil, fmt.Errorf("hash deb: %w", err) + } + if _, err := f.Seek(0, io.SeekStart); err != nil { + return nil, fmt.Errorf("seek deb: %w", err) + } + + control, err := readControlFromAR(f) + if err != nil { + return nil, err + } + + return &Deb{ + Control: control, + Size: size, + MD5sum: hex.EncodeToString(hMD5.Sum(nil)), + SHA1: hex.EncodeToString(hSHA1.Sum(nil)), + SHA256: hex.EncodeToString(hSHA256.Sum(nil)), + }, nil +} + +// readControlFromAR reads an ar stream and returns the parsed control stanza. +func readControlFromAR(r io.Reader) (Control, error) { + members, err := readAR(r) + if err != nil { + return Control{}, err + } + var debianBinary []byte + var controlTar []byte + var controlTarName string + for _, m := range members { + switch { + case m.Name == "debian-binary": + debianBinary = m.Data + case strings.HasPrefix(m.Name, "control.tar"): + controlTar = m.Data + controlTarName = m.Name + } + } + if debianBinary == nil { + return Control{}, fmt.Errorf("missing debian-binary member") + } + if !bytes.HasPrefix(bytes.TrimSpace(debianBinary), []byte("2.0")) { + return Control{}, fmt.Errorf("unsupported deb format (expected 2.0)") + } + if controlTar == nil { + return Control{}, fmt.Errorf("missing control.tar member") + } + + decompressed, err := decompressMember(controlTarName, controlTar) + if err != nil { + return Control{}, err + } + return parseControlTar(decompressed) +} + +// arMember is a single file within an ar archive. +type arMember struct { + Name string + Data []byte +} + +// readAR parses a Unix ar archive (the variant used by .deb: GNU/SysV style, +// with member names terminated by '/' and no long-name index needed for the +// short standard member names). +func readAR(r io.Reader) ([]arMember, error) { + br := newBlockReader(r) + magic := make([]byte, 8) + if _, err := io.ReadFull(br, magic); err != nil { + return nil, fmt.Errorf("read ar magic: %w", err) + } + if string(magic) != "!\n" { + return nil, fmt.Errorf("not an ar archive (bad magic)") + } + + var members []arMember + for { + header := make([]byte, 60) + n, err := io.ReadFull(br, header) + if err == io.EOF || (err == io.ErrUnexpectedEOF && n == 0) { + break + } + if err != nil { + return nil, fmt.Errorf("read ar header: %w", err) + } + if string(header[58:60]) != "`\n" { + return nil, fmt.Errorf("bad ar header terminator") + } + name := strings.TrimSpace(strings.TrimRight(string(header[0:16]), " ")) + name = strings.TrimSuffix(name, "/") + sizeStr := strings.TrimSpace(string(header[48:58])) + var size int64 + fmt.Sscanf(sizeStr, "%d", &size) + if size < 0 { + return nil, fmt.Errorf("negative ar member size") + } + data := make([]byte, size) + if _, err := io.ReadFull(br, data); err != nil { + return nil, fmt.Errorf("read ar member %q: %w", name, err) + } + members = append(members, arMember{Name: name, Data: data}) + if size%2 == 1 { + pad := make([]byte, 1) + if _, err := io.ReadFull(br, pad); err != nil { + return nil, fmt.Errorf("read ar padding: %w", err) + } + } + } + return members, nil +} + +// blockReader is a thin wrapper that ensures io.ReadFull semantics work on any +// io.Reader (it just forwards reads). +type blockReader struct { + r io.Reader +} + +func newBlockReader(r io.Reader) *blockReader { return &blockReader{r: r} } +func (b *blockReader) Read(p []byte) (int, error) { return b.r.Read(p) } + +// decompressMember decompresses a control.tar.* member based on its name +// extension. +func decompressMember(name string, data []byte) ([]byte, error) { + switch { + case strings.HasSuffix(name, ".gz"): + gz, err := gzip.NewReader(bytes.NewReader(data)) + if err != nil { + return nil, fmt.Errorf("gzip: %w", err) + } + defer gz.Close() + return io.ReadAll(gz) + case strings.HasSuffix(name, ".xz"): + xr, err := xz.NewReader(bytes.NewReader(data)) + if err != nil { + return nil, fmt.Errorf("xz: %w", err) + } + return io.ReadAll(xr) + case strings.HasSuffix(name, ".zst"): + zr, err := zstd.NewReader(bytes.NewReader(data)) + if err != nil { + return nil, fmt.Errorf("zstd: %w", err) + } + defer zr.Close() + return io.ReadAll(zr) + case strings.HasSuffix(name, ".tar"): + return data, nil + default: + return nil, fmt.Errorf("unknown control.tar compression: %s", name) + } +} + +// parseControlTar reads a tar stream and returns the first control stanza +// found in a file named "control" or "./control". +func parseControlTar(tarData []byte) (Control, error) { + tr := tar.NewReader(bytes.NewReader(tarData)) + for { + hdr, err := tr.Next() + if err == io.EOF { + break + } + if err != nil { + return Control{}, fmt.Errorf("read tar: %w", err) + } + name := strings.TrimPrefix(hdr.Name, "./") + if name == "control" { + body, err := io.ReadAll(tr) + if err != nil { + return Control{}, fmt.Errorf("read control: %w", err) + } + return ParseControl(bytes.NewReader(body)) + } + } + return Control{}, fmt.Errorf("control file not found in control.tar") +} + +// ParseControl parses a single RFC822-style control stanza. Continuation +// lines (starting with a space or tab) are appended to the previous field's +// value with the leading whitespace preserved as a single space. +func ParseControl(r io.Reader) (Control, error) { + data, err := io.ReadAll(r) + if err != nil { + return Control{}, fmt.Errorf("read control: %w", err) + } + raw := strings.TrimRight(string(data), "\n") + c := Control{Lookup: map[string]string{}, Raw: raw} + + var curKey, curVal string + flush := func() { + if curKey == "" { + return + } + c.Fields = append(c.Fields, Field{Key: curKey, Value: curVal}) + c.Lookup[strings.ToLower(curKey)] = curVal + curKey, curVal = "", "" + } + + for _, line := range strings.Split(raw, "\n") { + if line == "" { + flush() + continue + } + if line[0] == ' ' || line[0] == '\t' { + if curKey == "" { + return Control{}, fmt.Errorf("continuation line with no field") + } + // Debian control continuation: strip exactly one leading space, + // and a line that is just "." represents a blank line. + body := line + if body[0] == ' ' { + body = body[1:] + } else { + body = strings.TrimLeft(body, " \t") + } + if body == "." { + curVal += "\n" + } else { + curVal += "\n" + strings.TrimRight(body, " \t\r") + } + continue + } + colon := strings.IndexByte(line, ':') + if colon < 0 { + return Control{}, fmt.Errorf("malformed control line: %q", line) + } + flush() + curKey = strings.TrimSpace(line[:colon]) + curVal = strings.TrimSpace(line[colon+1:]) + } + flush() + + if len(c.Fields) == 0 { + return Control{}, fmt.Errorf("empty control stanza") + } + return c, nil +} + +// ShortDescription returns the short summary (the first line of Description). +func (c Control) ShortDescription() string { + desc := c.Get("Description") + if desc == "" { + return "" + } + if i := strings.IndexByte(desc, '\n'); i >= 0 { + return desc[:i] + } + return desc +} + +// LongDescription returns the extended description (everything after the short +// summary line). +func (c Control) LongDescription() string { + desc := c.Get("Description") + if desc == "" { + return "" + } + if i := strings.IndexByte(desc, '\n'); i >= 0 { + return strings.TrimLeft(desc[i+1:], "\n") + } + return "" +} + +// DescriptionMD5 returns the MD5 hex digest of the long description, matching +// Debian's Description-md5 Packages field. +func (c Control) DescriptionMD5() string { + sum := md5.Sum([]byte(c.LongDescription())) + return hex.EncodeToString(sum[:]) +} + +// IsControlFieldName reports whether s looks like a valid control field name +// (non-empty, colon-free, starts with a non-space, ASCII letters/digits/-). +func IsControlFieldName(s string) bool { + if s == "" { + return false + } + for _, r := range s { + if r >= 'a' && r <= 'z' { + continue + } + if r >= 'A' && r <= 'Z' { + continue + } + if r >= '0' && r <= '9' { + continue + } + if r == '-' { + continue + } + return false + } + return true +} + +// ensure unicode is referenced (reserved for stricter validation later). +var _ = unicode.IsLetter diff --git a/shared/deb/deb_test.go b/shared/deb/deb_test.go new file mode 100644 index 0000000..9a06061 --- /dev/null +++ b/shared/deb/deb_test.go @@ -0,0 +1,173 @@ +package deb + +import ( + "archive/tar" + "bytes" + "compress/gzip" + "os" + "path/filepath" + "strings" + "testing" +) + +// makeTestDeb builds a minimal valid .deb at path with the given control +// stanza text. +func makeTestDeb(t *testing.T, path, controlText string) { + t.Helper() + controlTar := buildControlTar(t, controlText) + dataTar := buildDataTar(t) + deb := buildAr(t, controlTar, dataTar) + if err := os.WriteFile(path, deb, 0o644); err != nil { + t.Fatalf("write deb: %v", err) + } +} + +func buildControlTar(t *testing.T, controlText string) []byte { + t.Helper() + var buf bytes.Buffer + gz := gzip.NewWriter(&buf) + tw := tar.NewWriter(gz) + addFile(t, tw, "control", controlText) + if err := tw.Close(); err != nil { + t.Fatalf("close control tar: %v", err) + } + if err := gz.Close(); err != nil { + t.Fatalf("close control gz: %v", err) + } + return buf.Bytes() +} + +func buildDataTar(t *testing.T) []byte { + t.Helper() + var buf bytes.Buffer + gz := gzip.NewWriter(&buf) + tw := tar.NewWriter(gz) + addFile(t, tw, "usr/share/doc/foo/README", "readme\n") + if err := tw.Close(); err != nil { + t.Fatalf("close data tar: %v", err) + } + if err := gz.Close(); err != nil { + t.Fatalf("close data gz: %v", err) + } + return buf.Bytes() +} + +func addFile(t *testing.T, tw *tar.Writer, name, body string) { + t.Helper() + if err := tw.WriteHeader(&tar.Header{ + Name: name, Mode: 0o644, Size: int64(len(body)), Typeflag: tar.TypeReg, + }); err != nil { + t.Fatalf("write tar header %s: %v", name, err) + } + if _, err := tw.Write([]byte(body)); err != nil { + t.Fatalf("write tar body %s: %v", name, err) + } +} + +func buildAr(t *testing.T, controlTar, dataTar []byte) []byte { + t.Helper() + var buf bytes.Buffer + buf.WriteString("!\n") + writeArMember(&buf, "debian-binary", []byte("2.0\n")) + writeArMember(&buf, "control.tar.gz", controlTar) + writeArMember(&buf, "data.tar.gz", dataTar) + return buf.Bytes() +} + +func writeArMember(buf *bytes.Buffer, name string, data []byte) { + header := make([]byte, 60) + for i := range header { + header[i] = ' ' + } + copy(header[0:], name+"/") + copy(header[48:], []byte(padLeft(len(data), 10))) + header[58] = '`' + header[59] = '\n' + buf.Write(header) + buf.Write(data) + if len(data)%2 == 1 { + buf.WriteByte('\n') + } +} + +func padLeft(n, width int) string { + s := []byte(strings.Repeat(" ", width)) + v := []byte(itoa(n)) + copy(s[len(s)-len(v):], v) + return string(s) +} + +func itoa(n int) string { + if n == 0 { + return "0" + } + var b []byte + for n > 0 { + b = append([]byte{byte('0' + n%10)}, b...) + n /= 10 + } + return string(b) +} + +func TestInspect(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "foo_1.0_amd64.deb") + controlText := `Package: foo +Version: 1.0 +Architecture: amd64 +Maintainer: Test +Installed-Size: 42 +Depends: libc6 (>= 2.31), bash | dash +Section: utils +Priority: optional +Homepage: https://example.com +Description: short summary + extended description line one + . + second paragraph. +` + makeTestDeb(t, path, controlText) + + d, err := Inspect(path) + if err != nil { + t.Fatalf("Inspect: %v", err) + } + if d.Control.Get("Package") != "foo" { + t.Fatalf("Package = %q", d.Control.Get("Package")) + } + if d.Control.Get("Version") != "1.0" { + t.Fatalf("Version = %q", d.Control.Get("Version")) + } + if d.Control.Get("Architecture") != "amd64" { + t.Fatalf("Architecture = %q", d.Control.Get("Architecture")) + } + if d.Control.Get("Depends") != "libc6 (>= 2.31), bash | dash" { + t.Fatalf("Depends = %q", d.Control.Get("Depends")) + } + if d.Size <= 0 { + t.Fatalf("Size = %d", d.Size) + } + if d.MD5sum == "" || d.SHA1 == "" || d.SHA256 == "" { + t.Fatalf("hashes empty: md5=%s sha1=%s sha256=%s", d.MD5sum, d.SHA1, d.SHA256) + } + if d.Control.ShortDescription() != "short summary" { + t.Fatalf("short = %q", d.Control.ShortDescription()) + } + long := d.Control.LongDescription() + if !strings.Contains(long, "extended description line one") || !strings.Contains(long, "second paragraph.") { + t.Fatalf("long = %q", long) + } + if d.Control.DescriptionMD5() == "" { + t.Fatal("empty description md5") + } +} + +func TestParseControlContinuation(t *testing.T) { + c, err := ParseControl(strings.NewReader("Package: bar\nVersion: 1\nDescription: short\n long\n .\n more\n")) + if err != nil { + t.Fatalf("ParseControl: %v", err) + } + if c.Get("Description") != "short\nlong\n\nmore" { + t.Fatalf("Description = %q", c.Get("Description")) + } +} diff --git a/shared/gpg/gpg.go b/shared/gpg/gpg.go new file mode 100644 index 0000000..ac26ccd --- /dev/null +++ b/shared/gpg/gpg.go @@ -0,0 +1,214 @@ +// Package gpg provides server-managed OpenPGP signing: key generation, +// armored export/import, clearsigning (for InRelease), and detached signing +// (for Release.gpg). It uses the pure-Go ProtonMail/go-crypto library so the +// server has no runtime dependency on the gpg binary. +package gpg + +import ( + "bytes" + "crypto" + "fmt" + "io" + "strings" + "time" + + "github.com/ProtonMail/go-crypto/openpgp" + "github.com/ProtonMail/go-crypto/openpgp/armor" + "github.com/ProtonMail/go-crypto/openpgp/clearsign" + "github.com/ProtonMail/go-crypto/openpgp/packet" +) + +// Key wraps an OpenPGP entity together with metadata urapt uses. +type Key struct { + Entity *openpgp.Entity + Fingerprint string + UserID string +} + +// GenerateKey creates a new RSA signing key with the given user-id (in the form +// "Name " or a plain name) and key size in bits. +func GenerateKey(userID string, bits int) (*Key, error) { + if bits <= 0 { + bits = 4096 + } + name, email := splitUserID(userID) + cfg := &packet.Config{ + RSABits: bits, + DefaultHash: crypto.SHA256, + V6Keys: false, + } + entity, err := openpgp.NewEntity(name, "", email, cfg) + if err != nil { + return nil, fmt.Errorf("new entity: %w", err) + } + return &Key{ + Entity: entity, + Fingerprint: fmt.Sprintf("%X", entity.PrimaryKey.Fingerprint), + UserID: userID, + }, nil +} + +// ParseArmoredPrivate decodes an ASCII-armored private key produced by +// ArmoredPrivate. +func ParseArmoredPrivate(armored string) (*Key, error) { + block, err := armor.Decode(strings.NewReader(armored)) + if err != nil { + return nil, fmt.Errorf("decode armor: %w", err) + } + if block.Type != "PGP PRIVATE KEY BLOCK" { + return nil, fmt.Errorf("unexpected armor type %q", block.Type) + } + entity, err := openpgp.ReadEntity(packet.NewReader(block.Body)) + if err != nil { + return nil, fmt.Errorf("read entity: %w", err) + } + uid := "" + if id := entity.PrimaryIdentity(); id != nil { + uid = id.Name + } + return &Key{ + Entity: entity, + Fingerprint: fmt.Sprintf("%X", entity.PrimaryKey.Fingerprint), + UserID: uid, + }, nil +} + +// ArmoredPublic returns the ASCII-armored public key. +func (k *Key) ArmoredPublic() (string, error) { + var buf bytes.Buffer + w, err := armor.Encode(&buf, "PGP PUBLIC KEY BLOCK", nil) + if err != nil { + return "", fmt.Errorf("armor encode: %w", err) + } + if err := k.Entity.Serialize(w); err != nil { + _ = w.Close() + return "", fmt.Errorf("serialize public: %w", err) + } + if err := w.Close(); err != nil { + return "", fmt.Errorf("close armor: %w", err) + } + return buf.String(), nil +} + +// ArmoredPrivate returns the ASCII-armored private key (unencrypted). +func (k *Key) ArmoredPrivate() (string, error) { + var buf bytes.Buffer + w, err := armor.Encode(&buf, "PGP PRIVATE KEY BLOCK", nil) + if err != nil { + return "", fmt.Errorf("armor encode: %w", err) + } + if err := k.Entity.SerializePrivate(w, nil); err != nil { + _ = w.Close() + return "", fmt.Errorf("serialize private: %w", err) + } + if err := w.Close(); err != nil { + return "", fmt.Errorf("close armor: %w", err) + } + return buf.String(), nil +} + +// ClearSign produces a clearsigned message (used for the InRelease file). +func (k *Key) ClearSign(data []byte) ([]byte, error) { + sk, ok := k.Entity.SigningKey(time.Now()) + if !ok { + return nil, fmt.Errorf("no signing key available") + } + var out bytes.Buffer + cfg := &packet.Config{DefaultHash: crypto.SHA256} + plaintext, err := clearsign.Encode(&out, sk.PrivateKey, cfg) + if err != nil { + return nil, fmt.Errorf("clearsign encode: %w", err) + } + if _, err := plaintext.Write(data); err != nil { + _ = plaintext.Close() + return nil, fmt.Errorf("write clearsign: %w", err) + } + if err := plaintext.Close(); err != nil { + return nil, fmt.Errorf("close clearsign: %w", err) + } + return out.Bytes(), nil +} + +// DetachedSign produces an ASCII-armored detached signature of data (used for +// Release.gpg). +func (k *Key) DetachedSign(data []byte) ([]byte, error) { + var out bytes.Buffer + cfg := &packet.Config{DefaultHash: crypto.SHA256} + if err := openpgp.ArmoredDetachSign(&out, k.Entity, bytes.NewReader(data), cfg); err != nil { + return nil, fmt.Errorf("detach sign: %w", err) + } + return out.Bytes(), nil +} + +// VerifyClearSign is a test helper that verifies a clearsigned block and +// returns the plaintext. +func VerifyClearSign(armoredPublic string, clearsigned []byte) (plaintext []byte, err error) { + key, err := ParseArmoredPublic(armoredPublic) + if err != nil { + return nil, err + } + block, rest := clearsign.Decode(clearsigned) + if block == nil { + return nil, fmt.Errorf("no clearsign block (rest=%d bytes)", len(rest)) + } + keyring := openpgp.EntityList{key.Entity} + if _, err := block.VerifySignature(keyring, nil); err != nil { + return nil, fmt.Errorf("verify: %w", err) + } + return block.Bytes, nil +} + +// VerifyDetached verifies an armored detached signature of data using the +// given armored public key. Test helper. +func VerifyDetached(armoredPublic string, data, armoredSig []byte) error { + key, err := ParseArmoredPublic(armoredPublic) + if err != nil { + return err + } + keyring := openpgp.EntityList{key.Entity} + if _, err := openpgp.CheckArmoredDetachedSignature(keyring, bytes.NewReader(data), bytes.NewReader(armoredSig), nil); err != nil { + return fmt.Errorf("verify: %w", err) + } + return nil +} + +// ParseArmoredPublic decodes an ASCII-armored public key. +func ParseArmoredPublic(armored string) (*Key, error) { + block, err := armor.Decode(strings.NewReader(armored)) + if err != nil { + return nil, fmt.Errorf("decode armor: %w", err) + } + if block.Type != "PGP PUBLIC KEY BLOCK" { + return nil, fmt.Errorf("unexpected armor type %q", block.Type) + } + entity, err := openpgp.ReadEntity(packet.NewReader(block.Body)) + if err != nil { + return nil, fmt.Errorf("read entity: %w", err) + } + uid := "" + if id := entity.PrimaryIdentity(); id != nil { + uid = id.Name + } + return &Key{ + Entity: entity, + Fingerprint: fmt.Sprintf("%X", entity.PrimaryKey.Fingerprint), + UserID: uid, + }, nil +} + +// splitUserID parses a user-id of the form "Name " into name and email. +// If no email brackets are present, the whole string is treated as the name. +func splitUserID(userID string) (name, email string) { + userID = strings.TrimSpace(userID) + i := strings.LastIndexByte(userID, '<') + j := strings.LastIndexByte(userID, '>') + if i >= 0 && j > i { + name = strings.TrimSpace(userID[:i]) + email = strings.TrimSpace(userID[i+1 : j]) + return name, email + } + return userID, "" +} + +// ensure io is referenced (used implicitly by armor/clearsign APIs). +var _ = io.EOF diff --git a/shared/gpg/gpg_test.go b/shared/gpg/gpg_test.go new file mode 100644 index 0000000..8b717d3 --- /dev/null +++ b/shared/gpg/gpg_test.go @@ -0,0 +1,68 @@ +package gpg + +import ( + "bytes" + "strings" + "testing" +) + +func TestGenerateAndSign(t *testing.T) { + k, err := GenerateKey("urapt-server ", 2048) + if err != nil { + t.Fatalf("GenerateKey: %v", err) + } + if k.Fingerprint == "" { + t.Fatal("empty fingerprint") + } + + pub, err := k.ArmoredPublic() + if err != nil { + t.Fatalf("ArmoredPublic: %v", err) + } + if !bytes.Contains([]byte(pub), []byte("BEGIN PGP PUBLIC KEY BLOCK")) { + t.Fatal("bad armored public") + } + priv, err := k.ArmoredPrivate() + if err != nil { + t.Fatalf("ArmoredPrivate: %v", err) + } + if !bytes.Contains([]byte(priv), []byte("BEGIN PGP PRIVATE KEY BLOCK")) { + t.Fatal("bad armored private") + } + + data := []byte("Origin: urapt\nSuite: stable\n\nContents here.\n") + + clear, err := k.ClearSign(data) + if err != nil { + t.Fatalf("ClearSign: %v", err) + } + if !bytes.Contains(clear, []byte("BEGIN PGP SIGNED MESSAGE")) { + t.Fatal("bad clearsign output") + } + pt, err := VerifyClearSign(pub, clear) + if err != nil { + t.Fatalf("VerifyClearSign: %v", err) + } + if strings.ReplaceAll(string(pt), "\r\n", "\n") != string(data) { + t.Fatalf("plaintext mismatch: got %q want %q", pt, data) + } + + det, err := k.DetachedSign(data) + if err != nil { + t.Fatalf("DetachedSign: %v", err) + } + if !bytes.Contains(det, []byte("BEGIN PGP SIGNATURE")) { + t.Fatal("bad detached output") + } + if err := VerifyDetached(pub, data, det); err != nil { + t.Fatalf("VerifyDetached: %v", err) + } + + k2, err := ParseArmoredPrivate(priv) + if err != nil { + t.Fatalf("ParseArmoredPrivate: %v", err) + } + if k2.Fingerprint != k.Fingerprint { + t.Fatalf("fingerprint mismatch after round-trip: %s != %s", k2.Fingerprint, k.Fingerprint) + } +} diff --git a/shared/httputil/httputil.go b/shared/httputil/httputil.go new file mode 100644 index 0000000..dd351df --- /dev/null +++ b/shared/httputil/httputil.go @@ -0,0 +1,118 @@ +// Package httputil provides small helpers for JSON I/O, uniform error +// rendering, and parsing of Authorization headers shared across the REST API +// and the APT endpoint. +package httputil + +import ( + "encoding/base64" + "encoding/json" + "fmt" + "net/http" + "strings" +) + +// APIError is the uniform JSON error body: {"error": {...}}. +type APIError struct { + Code string `json:"code"` + Message string `json:"message"` + Details any `json:"details,omitempty"` +} + +// ErrorEnvelope wraps an APIError. +type ErrorEnvelope struct { + Error APIError `json:"error"` +} + +// WriteJSON writes v as JSON with the given status code. +func WriteJSON(w http.ResponseWriter, status int, v any) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(status) + if v == nil { + return + } + _ = json.NewEncoder(w).Encode(v) +} + +// ReadJSON decodes r.Body into v. It limits the body to maxBytes. +func ReadJSON(r *http.Request, v any, maxBytes int64) error { + if maxBytes > 0 { + r.Body = http.MaxBytesReader(nil, r.Body, maxBytes) + } + dec := json.NewDecoder(r.Body) + dec.DisallowUnknownFields() + if err := dec.Decode(v); err != nil { + return err + } + return nil +} + +// WriteError renders a uniform error response. +func WriteError(w http.ResponseWriter, status int, code, message string, details ...any) { + e := APIError{Code: code, Message: message} + if len(details) > 0 { + e.Details = details[0] + } + WriteJSON(w, status, ErrorEnvelope{Error: e}) +} + +// WriteErrorf is WriteError with printf-style message formatting. +func WriteErrorf(w http.ResponseWriter, status int, code, format string, args ...any) { + WriteError(w, status, code, fmt.Sprintf(format, args...)) +} + +// Common error code constants. +const ( + CodeBadRequest = "bad_request" + CodeUnauthorized = "unauthorized" + CodeForbidden = "forbidden" + CodeNotFound = "not_found" + CodeConflict = "conflict" + CodePayloadTooLarge = "payload_too_large" + CodeInternal = "internal" +) + +// ParseBearer extracts the token from an "Authorization: Bearer " +// header. ok is false if the header is absent or malformed. +func ParseBearer(h http.Header) (token string, ok bool) { + v := h.Get("Authorization") + if v == "" { + return "", false + } + parts := strings.SplitN(v, " ", 2) + if len(parts) != 2 || !strings.EqualFold(parts[0], "Bearer") { + return "", false + } + t := strings.TrimSpace(parts[1]) + if t == "" { + return "", false + } + return t, true +} + +// ParseBasic extracts username/password from an "Authorization: Basic" +// header. ok is false if absent or malformed. +func ParseBasic(h http.Header) (username, password string, ok bool) { + v := h.Get("Authorization") + if v == "" { + return "", "", false + } + parts := strings.SplitN(v, " ", 2) + if len(parts) != 2 || !strings.EqualFold(parts[0], "Basic") { + return "", "", false + } + raw, err := base64.StdEncoding.DecodeString(strings.TrimSpace(parts[1])) + if err != nil { + return "", "", false + } + idx := strings.IndexByte(string(raw), ':') + if idx < 0 { + return "", "", false + } + return string(raw[:idx]), string(raw[idx+1:]), true +} + +// ChallengeBasic writes a 401 with a WWW-Authenticate Basic challenge. +func ChallengeBasic(w http.ResponseWriter, realm string) { + w.Header().Set("WWW-Authenticate", fmt.Sprintf(`Basic realm=%q, charset="UTF-8"`, realm)) + WriteError(w, http.StatusUnauthorized, CodeUnauthorized, "authentication required") +} diff --git a/shared/log/log.go b/shared/log/log.go new file mode 100644 index 0000000..1799ead --- /dev/null +++ b/shared/log/log.go @@ -0,0 +1,35 @@ +// Package log provides a thin structured logging wrapper over the standard +// library's log/slog package, with a configurable level. +package log + +import ( + "log/slog" + "os" + "strings" +) + +// Level names accepted by ParseLevel. +const ( + LevelDebug = "debug" + LevelInfo = "info" + LevelWarn = "warn" + LevelError = "error" +) + +// New constructs a slog.Logger writing to stderr at the given level. Unknown +// levels fall back to info. +func New(level string) *slog.Logger { + var lv slog.Level + switch strings.ToLower(strings.TrimSpace(level)) { + case LevelDebug: + lv = slog.LevelDebug + case LevelWarn: + lv = slog.LevelWarn + case LevelError: + lv = slog.LevelError + default: + lv = slog.LevelInfo + } + h := slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: lv}) + return slog.New(h) +} diff --git a/shared/models/models.go b/shared/models/models.go new file mode 100644 index 0000000..095a792 --- /dev/null +++ b/shared/models/models.go @@ -0,0 +1,172 @@ +// Package models defines the domain types used across urapt's database and API +// boundaries. These structs mirror the SQLite schema and are serialized into +// API DTOs by the restapi and apiclient packages. +package models + +// User is an urapt account. +type User struct { + ID string `json:"id"` + Username string `json:"username"` + IsAdmin bool `json:"is_admin"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +// APIToken is a revocable authentication token. The plaintext token is only +// returned at creation/login time; only its hash and a short display prefix +// are persisted. +type APIToken struct { + ID string `json:"id"` + UserID string `json:"user_id"` + Name string `json:"name"` + Prefix string `json:"prefix"` + Token string `json:"token,omitempty"` // plaintext, only on creation + CreatedAt string `json:"created_at"` + LastUsedAt *string `json:"last_used_at,omitempty"` + RevokedAt *string `json:"revoked_at,omitempty"` +} + +// Visibility is whether a repository is world-readable. +type Visibility string + +const ( + VisibilityPublic Visibility = "public" + VisibilityPrivate Visibility = "private" +) + +// Access is a user's role on a repository. +type Access string + +const ( + AccessRead Access = "read" + AccessWrite Access = "write" + AccessReadWrite Access = "read-write" + AccessAdmin Access = "admin" +) + +// ValidAccess reports whether s is a recognized access level. +func ValidAccess(s string) bool { + switch Access(s) { + case AccessRead, AccessWrite, AccessReadWrite, AccessAdmin: + return true + } + return false +} + +// Repository is a named APT repository owned by a user. +type Repository struct { + ID string `json:"id"` + Name string `json:"name"` + OwnerUserID string `json:"owner_user_id"` + Owner *User `json:"owner,omitempty"` + Visibility Visibility `json:"visibility"` + Description string `json:"description"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +// RepositoryMember is a user's access grant on a repository. +type RepositoryMember struct { + RepositoryID string `json:"repository_id"` + UserID string `json:"user_id"` + User *User `json:"user,omitempty"` + Access Access `json:"access"` + CreatedAt string `json:"created_at"` +} + +// Distribution (suite) within a repository. +type Distribution struct { + ID string `json:"id"` + RepositoryID string `json:"repository_id"` + Name string `json:"name"` + CreatedAt string `json:"created_at"` +} + +// Component within a distribution (e.g. main, contrib). +type Component struct { + ID string `json:"id"` + DistributionID string `json:"distribution_id"` + Name string `json:"name"` + CreatedAt string `json:"created_at"` +} + +// Architecture configured for a distribution (e.g. amd64). The special +// architecture "all" is implicit and never stored. +type Architecture struct { + ID string `json:"id"` + DistributionID string `json:"distribution_id"` + Name string `json:"name"` + CreatedAt string `json:"created_at"` +} + +// Package is one uploaded .deb version and its extracted control metadata. +type Package struct { + ID string `json:"id"` + RepositoryID string `json:"repository_id"` + DistributionID string `json:"distribution_id"` + ComponentID string `json:"component_id"` + Name string `json:"name"` + Version string `json:"version"` + Architecture string `json:"architecture"` + Source string `json:"source,omitempty"` + Maintainer string `json:"maintainer,omitempty"` + Priority string `json:"priority,omitempty"` + Section string `json:"section,omitempty"` + Origin string `json:"origin,omitempty"` + Homepage string `json:"homepage,omitempty"` + Description string `json:"description,omitempty"` + DescriptionMD5 string `json:"description_md5,omitempty"` + Depends string `json:"depends,omitempty"` + PreDepends string `json:"pre_depends,omitempty"` + Recommends string `json:"recommends,omitempty"` + Suggests string `json:"suggests,omitempty"` + Conflicts string `json:"conflicts,omitempty"` + Breaks string `json:"breaks,omitempty"` + Provides string `json:"provides,omitempty"` + Replaces string `json:"replaces,omitempty"` + Enhances string `json:"enhances,omitempty"` + InstalledSize int64 `json:"installed_size,omitempty"` + Essential string `json:"essential,omitempty"` + BuiltUsing string `json:"built_using,omitempty"` + Tag string `json:"tag,omitempty"` + RawControl string `json:"raw_control"` + Filename string `json:"filename"` + PoolPath string `json:"pool_path"` + Size int64 `json:"size"` + MD5sum string `json:"md5sum"` + SHA1 string `json:"sha1"` + SHA256 string `json:"sha256"` + UploadedByUserID string `json:"uploaded_by_user_id"` + CreatedAt string `json:"created_at"` +} + +// Blob is a content-addressed .deb file on disk, reference-counted for dedup. +type Blob struct { + SHA256 string `json:"sha256"` + Filename string `json:"filename"` + Size int64 `json:"size"` + RefCount int64 `json:"ref_count"` + CreatedAt string `json:"created_at"` +} + +// GPGKey is a server-managed signing key. +type GPGKey struct { + ID string `json:"id"` + Fingerprint string `json:"fingerprint"` + UserID string `json:"user_id"` + PublicKeyArmored string `json:"public_key_armored"` + PrivateKeyArmored string `json:"-"` // never serialized in API responses + IsDefault bool `json:"is_default"` + CreatedAt string `json:"created_at"` +} + +// AuditLogEntry is a best-effort record of a mutating action. +type AuditLogEntry struct { + ID string `json:"id"` + UserID *string `json:"user_id,omitempty"` + RepositoryID *string `json:"repository_id,omitempty"` + Action string `json:"action"` + Target string `json:"target"` + Details string `json:"details,omitempty"` + CreatedAt string `json:"created_at"` +} diff --git a/shared/version/version.go b/shared/version/version.go new file mode 100644 index 0000000..26c5244 --- /dev/null +++ b/shared/version/version.go @@ -0,0 +1,19 @@ +// Package version holds build-time version information for urapt. +package version + +import "runtime/debug" + +// Version is the urapt build version. It is populated from VCS info when +// available, otherwise it falls back to "dev". +var Version = "dev" + +func init() { + if info, ok := debug.ReadBuildInfo(); ok { + for _, s := range info.Settings { + if s.Key == "vcs.revision" { + Version = s.Value + return + } + } + } +} diff --git a/urapt-server.toml.example b/urapt-server.toml.example new file mode 100644 index 0000000..3bc9ff8 --- /dev/null +++ b/urapt-server.toml.example @@ -0,0 +1,23 @@ +# urapt-server configuration. Copy to urapt-server.toml and edit. +# Any value here can be overridden by URAPT_* environment variables or CLI flags. + +bind = "0.0.0.0:8080" +base_url = "https://apt.example.com" +store_dir = "./store" +log_level = "info" + +# Signing key generated on first start if none exists. +signing_key_type = "rsa" +signing_key_bits = 4096 +signing_key_user_id = "urapt-server " + +# Maximum .deb upload size (bytes). +max_package_size = 1073741824 + +# Allow new users to self-register. The first registrant always becomes admin. +open_registration = true + +# Optional built-in TLS (otherwise use a TLS-terminating reverse proxy). +tls_enabled = false +# tls_cert = "/path/to/fullchain.pem" +# tls_key = "/path/to/privkey.pem"