Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,4 @@
!/go.mod
!/go.sum
!main.go
!/docker/entrypoint.sh
21 changes: 17 additions & 4 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,14 @@ jobs:
goarch: '386'
- goos: linux
goarch: arm
- goos: windows
goarch: amd64
ext: .exe
- goos: windows
goarch: arm64
ext: .exe
env:
ASSET: dynacat-${{ matrix.goos }}-${{ matrix.goarch }}${{ matrix.goos == 'windows' && '.zip' || '.tar.gz' }}
steps:
- name: Checkout the target Git reference
uses: actions/checkout@v6
Expand All @@ -136,15 +144,20 @@ jobs:
GOOS: ${{ matrix.goos }}
GOARCH: ${{ matrix.goarch }}
APP_VERSION: ${{ github.ref_name }}
run: go build -ldflags="-X github.com/Panonim/dynacat/internal/dynacat.buildVersion=${APP_VERSION}" -o dynacat .
run: go build -ldflags="-X github.com/Panonim/dynacat/internal/dynacat.buildVersion=${APP_VERSION}" -o dynacat${{ matrix.ext }} .

- name: Create tarball
run: tar -czf dynacat-${{ matrix.goos }}-${{ matrix.goarch }}.tar.gz dynacat
- name: Create archive
run: |
if [ "${{ matrix.goos }}" = "windows" ]; then
zip "${ASSET}" dynacat.exe
else
tar -czf "${ASSET}" dynacat
fi

- name: Upload release asset
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: gh release upload "${GITHUB_REF_NAME}" dynacat-${{ matrix.goos }}-${{ matrix.goarch }}.tar.gz --clobber
run: gh release upload "${GITHUB_REF_NAME}" "${ASSET}" --clobber

notify-discord:
needs: [deploy, release-binaries]
Expand Down
6 changes: 6 additions & 0 deletions .goreleaser.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ dockers:
goarch: amd64
use: buildx
dockerfile: Dockerfile.goreleaser
extra_files:
- docker/entrypoint.sh

- image_templates:
- &arm64v8_image "{{ .ProjectName }}:{{ .Tag }}-arm64"
Expand All @@ -48,6 +50,8 @@ dockers:
goarch: arm64
use: buildx
dockerfile: Dockerfile.goreleaser
extra_files:
- docker/entrypoint.sh

- image_templates:
- &armv7_image "{{ .ProjectName }}:{{ .Tag }}-armv7"
Expand All @@ -57,6 +61,8 @@ dockers:
goarm: 7
use: buildx
dockerfile: Dockerfile.goreleaser
extra_files:
- docker/entrypoint.sh

docker_manifests:
- name_template: "{{ .ProjectName }}:{{ .Tag }}"
Expand Down
5 changes: 3 additions & 2 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,12 @@ RUN --mount=type=cache,target=/go/pkg/mod \

FROM alpine:3.21

RUN apk add --no-cache zfs
RUN apk add --no-cache zfs su-exec

WORKDIR /app
COPY --from=builder /app/dynacat .
COPY --chmod=755 docker/entrypoint.sh /app/entrypoint.sh
RUN mkdir -p /app/config

EXPOSE 8080/tcp
ENTRYPOINT ["/app/dynacat", "--config", "/app/config/dynacat.yml"]
ENTRYPOINT ["/app/entrypoint.sh", "--config", "/app/config/dynacat.yml"]
5 changes: 4 additions & 1 deletion Dockerfile.goreleaser
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
FROM alpine:3.21

RUN apk add --no-cache su-exec

WORKDIR /app
COPY dynacat .
COPY --chmod=755 docker/entrypoint.sh /app/entrypoint.sh

EXPOSE 8080/tcp
ENTRYPOINT ["/app/dynacat", "--config", "/app/config/dynacat.yml"]
ENTRYPOINT ["/app/entrypoint.sh", "--config", "/app/config/dynacat.yml"]
55 changes: 55 additions & 0 deletions docker/entrypoint.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
#!/bin/sh
set -e

# Without PUID, or when already started as a non-root user, run as is.
if [ "$(id -u)" != "0" ] || [ -z "$PUID" ]; then
exec /app/dynacat "$@"
fi

PGID="${PGID:-$PUID}"

case "$PUID$PGID" in
*[!0-9]*)
echo "PUID and PGID must be numeric" >&2
exit 1
;;
esac

name_by_id() { awk -F: -v id="$2" '$3 == id { print $1; exit }' "$1"; }

group="$(name_by_id /etc/group "$PGID")"
if [ -z "$group" ]; then
group=dynacat
addgroup -g "$PGID" "$group"
fi

user="$(name_by_id /etc/passwd "$PUID")"
if [ -z "$user" ]; then
user=dynacat
adduser -D -H -h /app -s /sbin/nologin -u "$PUID" -G "$group" "$user"
fi

# A named user lets su-exec pick up supplementary groups, which the docker socket needs.
if [ -S /var/run/docker.sock ]; then
sock_gid="$(stat -c %g /var/run/docker.sock)"
if [ "$sock_gid" != "0" ]; then
sock_group="$(name_by_id /etc/group "$sock_gid")"
if [ -z "$sock_group" ]; then
sock_group=docker-host
addgroup -g "$sock_gid" "$sock_group"
fi
addgroup "$user" "$sock_group" 2>/dev/null || true
fi
fi

# Only ownership is changed, mode bits stay as they are and symlinks are never followed.
for dir in /app/config /app/assets /app/.cache; do
mkdir -p "$dir"
find "$dir" \( ! -user "$PUID" -o ! -group "$PGID" \) -exec chown -h "$PUID:$PGID" {} + ||
echo "Could not change ownership of $dir to $PUID:$PGID" >&2
done

if [ "$(id -g "$user")" = "$PGID" ]; then
exec su-exec "$user" /app/dynacat "$@"
fi
exec su-exec "$PUID:$PGID" /app/dynacat "$@"
4 changes: 4 additions & 0 deletions docs/docs/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@
- Calendar now shows releases on the previous and next month days visible in the grid -> https://github.com/Panonim/dynacat/issues/156
- Calendar now properly shows upcoming shows -> https://github.com/Panonim/dynacat/issues/156
- Added a `grid` style to the `bookmarks` widget -> https://github.com/Panonim/dynacat/issues/162
- Allowed non-root users to use the folders easily -> https://github.com/Panonim/dynacat/issues/165
- Added .exe support for the releases
- Full text finding for bookmarks in `search` widget -> https://github.com/Panonim/dynacat/issues/167
- Added Small/Full column size controls to the page editor

# Changes for 3.0.0
- Moved the compose template to [Panonim/dynacat-compose-template](https://github.com/Panonim/dynacat-compose-template), the install command is now a single `curl | tar` with no `sed` renaming
Expand Down
12 changes: 12 additions & 0 deletions docs/docs/docker-options.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,18 @@ volumes:
- /etc:/host-etc:ro
```

### PUID / PGID

Runs Dynacat as this UID and GID instead of root. On startup the container changes the owner of `config`, `assets` and its image cache to match, so the UI editor can save without a manual `chown`. Permission bits are left untouched. `PGID` defaults to `PUID`.

```yaml
environment:
- PUID=1000
- PGID=1000
```

See [Running as a non-root user](installation.md#running-as-a-non-root-user) for details and the `user` alternative.

## Dynamic Refreshing

Dynamic refreshing allows widgets to automatically update their data at specified intervals. This behavior can be controlled through two mechanisms:
Expand Down
25 changes: 22 additions & 3 deletions docs/docs/installation.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,15 +70,33 @@ services:
### Running as a non-root user

The image runs as root by default. Nothing in Dynacat needs it, and dropping to your own UID
and GID is recommended, especially if you mount the docker socket:
and GID is recommended, especially if you mount the docker socket. The easiest way is to set
`PUID` and `PGID`:

```yaml
services:
dynacat:
environment:
- PUID=1000
- PGID=1000
```

On startup the container hands `config`, `assets` and its image cache over to that UID and GID,
then runs Dynacat as that user. Only the owner is changed, the permission bits of your files are
left as they are and symlinks are never followed. If the docker socket is mounted, the user is
added to the group that owns it. `PGID` defaults to `PUID` when left out.

#### Using `user` instead

If you would rather the container never start as root, use `user` instead of `PUID`/`PGID`:

```yaml
services:
dynacat:
user: "1000:1000"
```

Two things have to line up for this to work:
The container cannot fix ownership in this mode, so two things have to line up yourself:

- `config` and `assets` have to be readable and writable by that user, otherwise the UI editor
cannot save and the dynawidgets cache cannot be written: `chown -R 1000:1000 config assets`.
Expand All @@ -91,7 +109,8 @@ server:
```

If you mount the docker socket for the docker widgets, the user also has to be in the group
that owns it, which is normally `docker`.
that owns it, which is normally `docker`. When a folder is not writable, Dynacat logs a warning
on startup with the exact `chown` command to run.

Then, create a new directories called `config` & `assets` and download the example starting [`dynacat.yml`](https://github.com/Panonim/dynacat/blob/main/docs/docs/dynacat.yml) file into it by running:

Expand Down
4 changes: 4 additions & 0 deletions internal/dynacat/cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,10 @@ func parseCliOptions() (*cliOptions, error) {
intent = cliIntentDiagnose
case "secret:make":
intent = cliIntentSecretMake
case "password:hash":
return nil, fmt.Errorf("missing password, usage: dynacat password:hash <password>")
case "mountpoint:info":
return nil, fmt.Errorf("missing path, usage: dynacat mountpoint:info <path>")
default:
return nil, unknownCommandErr
}
Expand Down
46 changes: 42 additions & 4 deletions internal/dynacat/config-editor.go
Original file line number Diff line number Diff line change
Expand Up @@ -121,10 +121,17 @@ type editorMutation struct {
PresetKey string `json:"presetKey"`
}

type editorPermissionError struct{ path string }
type editorPermissionError struct {
path string
err error
}

func (e *editorPermissionError) Error() string {
return fmt.Sprintf("cannot write %s: the config directory is read only or lacks write permission", filepath.Base(e.path))
msg := fmt.Sprintf("cannot write %s: the config directory is read only or lacks write permission", filepath.Base(e.path))
if hint := ownershipHint(e.err); hint != "" {
msg += ", " + hint
}
return msg
}

type editorDisabledError struct{}
Expand Down Expand Up @@ -198,6 +205,28 @@ func isWriteBlockedError(err error) bool {
return os.IsPermission(err) || errors.Is(err, syscall.EROFS)
}

// ownershipHint explains how to fix a permission error caused by file ownership, empty when chown would not help.
func ownershipHint(err error) string {
uid, gid := os.Getuid(), os.Getgid()
if !os.IsPermission(err) || uid <= 0 {
return ""
}
return fmt.Sprintf("dynacat runs as %d:%d, run chown -R %d:%d on the mounted config and assets folders or set PUID/PGID instead of user", uid, gid, uid, gid)
}

// warnIfDirNotWritable logs at startup when dir exists but cannot be written to.
func warnIfDirNotWritable(dir string) {
f, err := os.CreateTemp(dir, ".write-check-*")
if err != nil {
if isWriteBlockedError(err) {
slog.Warn("Directory is not writable, the UI editor and caches cannot save to it", "dir", dir, "hint", ownershipHint(err))
}
return
}
f.Close()
os.Remove(f.Name())
}

type editorValidationError struct {
err error
field string
Expand Down Expand Up @@ -489,6 +518,15 @@ func mutateColumns(columns *yaml.Node, m editorMutation, docs *editorDocs) error
slot.seq.Content = removeNode(slot.seq.Content, slot.index)
pruneEmptyInclude(slot)
return nil
case "setColumnSize":
if !validIndex(colSlots, m.Column) {
return fmt.Errorf("column %d out of range", m.Column)
}
if m.Size != "small" && m.Size != "full" {
return fmt.Errorf("invalid column size %q", m.Size)
}
setMappingKey(colSlots[m.Column].node, "size", scalarNode(m.Size))
return nil
}

widgets, owner, err := resolveWidgets(colSlots, m.Path, docs)
Expand Down Expand Up @@ -765,7 +803,7 @@ func (a *application) addPageFile(mainDoc *yaml.Node, mainPath string, m editorM
}
if err := os.WriteFile(pagePath, pageContents, 0o644); err != nil {
if isWriteBlockedError(err) {
return &editorPermissionError{pagePath}
return &editorPermissionError{pagePath, err}
}
return err
}
Expand Down Expand Up @@ -896,7 +934,7 @@ func (a *application) writeConfigCandidates(candidates map[string][]byte) error
if err := os.WriteFile(path, candidate, perms[path]); err != nil {
rollback()
if isWriteBlockedError(err) {
return &editorPermissionError{path}
return &editorPermissionError{path, err}
}
return err
}
Expand Down
3 changes: 3 additions & 0 deletions internal/dynacat/dynacat.go
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,9 @@ func newApplication(c *config) (*application, error) {
cacheDir = absCacheDir
}
if err := os.MkdirAll(cacheDir, 0o755); err != nil {
if hint := ownershipHint(err); hint != "" {
return nil, fmt.Errorf("creating cache-dir: %v, %s", err, hint)
}
return nil, fmt.Errorf("creating cache-dir: %v", err)
}
config.Server.CacheDir = cacheDir
Expand Down
3 changes: 3 additions & 0 deletions internal/dynacat/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,9 @@ func serveApp(configPath string) error {
hadValidConfigOnStartup := false
var stopServer func() error

warnIfDirNotWritable(filepath.Dir(configPath))
warnIfDirNotWritable(filepath.Dir(dynawidgetsAssetsDir))

onChange := func(newContents []byte) {
if stopServer != nil {
slog.Info("Config file changed, reloading")
Expand Down
34 changes: 13 additions & 21 deletions internal/dynacat/static/css/editor.css
Original file line number Diff line number Diff line change
Expand Up @@ -156,16 +156,17 @@ body.editing .page-column {
background: var(--color-widget-background);
}

body.editing .page-column:not(:has(> .widget)) {
align-items: center;
justify-content: center;
}

body.editing .page-column:not(:has(> .widget))::after {
content: "Drop widgets here";
order: -1;
display: flex;
align-items: center;
justify-content: center;
min-height: 120px;
border: 1px dashed var(--color-separator);
border-radius: var(--border-radius);
color: var(--color-text-subdue);
font-size: 1.2rem;
opacity: 0.5;
pointer-events: none;
}

Expand Down Expand Up @@ -288,24 +289,15 @@ body.editing .page-column:not(:has(> .widget))::after {
}

.editor-column-tools {
display: flex;
flex-wrap: wrap;
justify-content: center;
padding-top: 8px;
padding: 6px 0 2px;
font-size: 1.3rem;
}

.editor-column-remove {
display: flex;
align-items: center;
gap: 6px;
padding: 5px 10px;
border: none;
border-radius: var(--border-radius);
background: transparent;
color: var(--color-text-subdue);
font: inherit;
font-size: 1.1rem;
cursor: pointer;
transition: color 0.15s;
.editor-column-size.active {
color: var(--color-primary);
cursor: default;
}

.editor-column-remove:hover {
Expand Down
Loading
Loading